chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:54 +08:00
commit 4a4a1fed67
721 changed files with 262090 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
from typing import TYPE_CHECKING, Any
from ._version import __version__ as __version__
__all__ = [
"LightRAG",
"QueryParam",
"RoleLLMConfig",
"RoleSpec",
"ROLES",
"__version__",
]
if TYPE_CHECKING:
from .lightrag import (
LightRAG as LightRAG,
QueryParam as QueryParam,
ROLES as ROLES,
RoleLLMConfig as RoleLLMConfig,
RoleSpec as RoleSpec,
)
_LAZY_EXPORTS = {"LightRAG", "QueryParam", "RoleLLMConfig", "RoleSpec", "ROLES"}
def __getattr__(name: str) -> Any:
if name in _LAZY_EXPORTS:
from .lightrag import LightRAG, QueryParam, RoleLLMConfig, RoleSpec, ROLES
values = {
"LightRAG": LightRAG,
"QueryParam": QueryParam,
"RoleLLMConfig": RoleLLMConfig,
"RoleSpec": RoleSpec,
"ROLES": ROLES,
}
value = values[name]
globals()[name] = value
return value
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__author__ = "Zirui Guo"
__url__ = "https://github.com/HKUDS/LightRAG"
+4
View File
@@ -0,0 +1,4 @@
"""Lightweight version definitions shared by packaging and runtime code."""
__version__ = "1.5.5"
__api_version__ = "0315"
+156
View File
@@ -0,0 +1,156 @@
"""Addon parameters: observable mapping + normalization helper.
``addon_params`` is a free-form configuration dict on :class:`LightRAG` that
controls things like summary language and entity-type prompt overrides. The
module exposes:
- :class:`ObservableAddonParams` — a ``dict`` subclass that calls a callback
whenever the contents change so the LightRAG runtime can invalidate cached
derived state.
- :func:`default_addon_params` — environment-driven defaults.
- :func:`normalize_addon_params` — converts an arbitrary input into a plain
``dict`` with the env-driven defaults backfilled.
"""
from __future__ import annotations
from typing import Any, Callable, Mapping
from lightrag.constants import DEFAULT_SUMMARY_LANGUAGE
from lightrag.utils import get_env_value, logger
# Keys that used to live in addon_params but have been superseded by
# per-document ``process_options``. We log once when callers still pass them
# so existing configs surface their drift without breaking.
_DEPRECATED_ADDON_PARAM_KEYS: tuple[str, ...] = ("enable_multimodal_pipeline",)
_warned_deprecated_keys: set[str] = set()
def _emit_deprecated_addon_warnings(params: Mapping[str, Any]) -> None:
for key in _DEPRECATED_ADDON_PARAM_KEYS:
if key in params and key not in _warned_deprecated_keys:
logger.warning(
f"addon_params['{key}'] is deprecated and ignored; per-document "
f"behaviour is now controlled by filename-hint process_options "
f"(see docs/FileProcessingConfiguration-zh.md)."
)
_warned_deprecated_keys.add(key)
def default_addon_params() -> dict[str, Any]:
# Lazy import to avoid the parser_routing → utils → … cycle that
# would otherwise form when parser_routing imports back into this
# module via ``LightRAG`` construction paths.
from lightrag.parser.routing import default_chunker_config
return {
"language": get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE, str),
"entity_type_prompt_file": get_env_value("ENTITY_TYPE_PROMPT_FILE", "", str),
# Per-strategy chunker parameters; mutate at runtime (e.g.
# ``rag.addon_params["chunker"]["recursive_character"]["separators"]
# = [...]``) to change defaults applied to subsequently
# enqueued documents. Per-document snapshots are persisted to
# ``full_docs[doc_id]["chunk_options"]`` at enqueue time and
# are not affected by later runtime mutations.
"chunker": default_chunker_config(),
}
def normalize_addon_params(addon_params: Mapping[str, Any] | None) -> dict[str, Any]:
"""Coerce ``addon_params`` to a plain dict with env defaults backfilled."""
from lightrag.parser.routing import default_chunker_config
if addon_params is None:
normalized = default_addon_params()
elif isinstance(addon_params, Mapping):
_emit_deprecated_addon_warnings(addon_params)
normalized = {
k: v
for k, v in addon_params.items()
if k not in _DEPRECATED_ADDON_PARAM_KEYS
}
else:
raise TypeError(
f"addon_params must be a Mapping or None, got {type(addon_params).__name__}"
)
# When the caller supplies addon_params explicitly, the dataclass
# default_factory is skipped — fall back to environment variables so
# ENTITY_TYPE_PROMPT_FILE / SUMMARY_LANGUAGE / chunker still apply.
normalized.setdefault(
"language", get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE, str)
)
normalized.setdefault(
"entity_type_prompt_file",
get_env_value("ENTITY_TYPE_PROMPT_FILE", "", str),
)
# Build the chunker default lazily — `default_chunker_config()` reads env
# vars (e.g. CHUNK_R_SEPARATORS via json.loads) and would raise on a
# malformed value, which would prevent an explicit caller-supplied
# `chunker` from bypassing a broken environment.
if "chunker" not in normalized:
normalized["chunker"] = default_chunker_config()
return normalized
class ObservableAddonParams(dict[str, Any]):
def __init__(
self,
*args: Any,
on_change: Callable[[], None] | None = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self._on_change = on_change
def _changed(self) -> None:
if self._on_change is not None:
self._on_change()
def __setitem__(self, key: str, value: Any) -> None:
super().__setitem__(key, value)
self._changed()
def __delitem__(self, key: str) -> None:
super().__delitem__(key)
self._changed()
def clear(self) -> None:
if self:
super().clear()
self._changed()
def pop(self, key: str, default: Any = ...):
existed = key in self
if default is ...:
value = super().pop(key)
self._changed()
else:
value = super().pop(key, default)
if existed:
self._changed()
return value
def popitem(self) -> tuple[str, Any]:
item = super().popitem()
self._changed()
return item
def setdefault(self, key: str, default: Any = None) -> Any:
if key in self:
return self[key]
value = super().setdefault(key, default)
self._changed()
return value
def update(self, *args: Any, **kwargs: Any) -> None:
if not args and not kwargs:
return
super().update(*args, **kwargs)
self._changed()
def __ior__(self, other: Mapping[str, Any]):
super().__ior__(other)
self._changed()
return self
+7
View File
@@ -0,0 +1,7 @@
AZURE_OPENAI_API_VERSION=2024-08-01-preview
AZURE_OPENAI_DEPLOYMENT=gpt-4o
AZURE_OPENAI_API_KEY=myapikey
AZURE_OPENAI_ENDPOINT=https://myendpoint.openai.azure.com
AZURE_EMBEDDING_DEPLOYMENT=text-embedding-3-large
AZURE_EMBEDDING_API_VERSION=2023-05-15
+2
View File
@@ -0,0 +1,2 @@
inputs
rag_storage
+1
View File
@@ -0,0 +1 @@
from .._version import __api_version__ as __api_version__
+163
View File
@@ -0,0 +1,163 @@
from datetime import datetime, timedelta, timezone
import jwt
from dotenv import load_dotenv
from fastapi import HTTPException, status
from pydantic import BaseModel
from ..utils import logger
from .config import DEFAULT_TOKEN_SECRET, global_args
from .passwords import verify_password
# use the .env that is inside the current folder
# allows to use different .env file for each lightrag instance
# the OS environment variables take precedence over the .env file
load_dotenv(dotenv_path=".env", override=False)
class TokenPayload(BaseModel):
sub: str # Username
exp: datetime # Expiration time
role: str = "user" # User role, default is regular user
metadata: dict = {} # Additional metadata
class AuthHandler:
def __init__(self):
auth_accounts = global_args.auth_accounts
self.secret = global_args.token_secret
if not self.secret:
if auth_accounts:
raise ValueError(
"TOKEN_SECRET must be explicitly set to a non-default value when AUTH_ACCOUNTS is configured."
)
self.secret = DEFAULT_TOKEN_SECRET
logger.warning(
"TOKEN_SECRET not set and AUTH_ACCOUNTS is not configured. "
"Falling back to the default guest-mode JWT secret. "
)
algorithm = global_args.jwt_algorithm
if not algorithm or algorithm.lower() == "none":
raise ValueError(
"JWT_ALGORITHM must be set to a secure algorithm (e.g. HS256). "
"The 'none' algorithm is not permitted."
)
self.algorithm = algorithm
self.expire_hours = global_args.token_expire_hours
self.guest_expire_hours = global_args.guest_token_expire_hours
self.accounts = {}
invalid_accounts = []
if auth_accounts:
for account in auth_accounts.split(","):
try:
username, password = account.split(":", 1)
if not username or not password:
raise ValueError
self.accounts[username] = password
except ValueError:
invalid_accounts.append(account)
if invalid_accounts:
invalid_entries = ", ".join(invalid_accounts)
logger.error(f"Invalid account format in AUTH_ACCOUNTS: {invalid_entries}")
raise ValueError(
"AUTH_ACCOUNTS must use comma-separated user:password pairs."
)
def verify_password(self, username: str, plain_password: str) -> bool:
"""
Verify password for a user. Supports explicit bcrypt values and plaintext.
Args:
username: Username to verify
plain_password: Plaintext password to check
Returns:
bool: True if password is correct, False otherwise
"""
if username not in self.accounts:
return False
stored_password = self.accounts[username]
return verify_password(plain_password, stored_password)
def create_token(
self,
username: str,
role: str = "user",
custom_expire_hours: int = None,
metadata: dict = None,
) -> str:
"""
Create JWT token
Args:
username: Username
role: User role, default is "user", guest is "guest"
custom_expire_hours: Custom expiration time (hours), if None use default value
metadata: Additional metadata
Returns:
str: Encoded JWT token
"""
# Choose default expiration time based on role
if custom_expire_hours is None:
if role == "guest":
expire_hours = self.guest_expire_hours
else:
expire_hours = self.expire_hours
else:
expire_hours = custom_expire_hours
expire = datetime.now(timezone.utc) + timedelta(hours=expire_hours)
# Create payload
payload = TokenPayload(
sub=username, exp=expire, role=role, metadata=metadata or {}
)
return jwt.encode(payload.model_dump(), self.secret, algorithm=self.algorithm)
def validate_token(self, token: str) -> dict:
"""
Validate JWT token
Args:
token: JWT token
Returns:
dict: Dictionary containing user information
Raises:
HTTPException: If token is invalid or expired
"""
try:
# Explicitly exclude 'none' to prevent algorithm confusion attacks
allowed_algorithms = [self.algorithm]
if "none" in (a.lower() for a in allowed_algorithms):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Insecure JWT algorithm configuration",
)
payload = jwt.decode(token, self.secret, algorithms=allowed_algorithms)
expire_timestamp = payload["exp"]
expire_time = datetime.fromtimestamp(expire_timestamp, timezone.utc)
if datetime.now(timezone.utc) > expire_time:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
)
# Return complete payload instead of just username
return {
"username": payload["sub"],
"role": payload.get("role", "user"),
"metadata": payload.get("metadata", {}),
"exp": expire_time,
}
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)
auth_handler = AuthHandler()
+873
View File
@@ -0,0 +1,873 @@
"""
Configs for the LightRAG API.
"""
import os
import re
import argparse
import logging
from dotenv import load_dotenv
from lightrag import ROLES
from lightrag.utils import get_env_value, logger
from lightrag.llm.binding_options import (
BedrockLLMOptions,
GeminiEmbeddingOptions,
GeminiLLMOptions,
OllamaEmbeddingOptions,
OllamaLLMOptions,
OpenAILLMOptions,
)
from lightrag.base import OllamaServerInfos
import sys
from lightrag.constants import (
DEFAULT_WOKERS,
DEFAULT_TIMEOUT,
DEFAULT_TOP_K,
DEFAULT_CHUNK_TOP_K,
DEFAULT_MAX_ENTITY_TOKENS,
DEFAULT_MAX_RELATION_TOKENS,
DEFAULT_MAX_TOTAL_TOKENS,
DEFAULT_COSINE_THRESHOLD,
DEFAULT_RELATED_CHUNK_NUMBER,
DEFAULT_MIN_RERANK_SCORE,
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
DEFAULT_MAX_ASYNC,
DEFAULT_MAX_PARALLEL_INSERT,
DEFAULT_SUMMARY_MAX_TOKENS,
DEFAULT_SUMMARY_LENGTH_RECOMMENDED,
DEFAULT_SUMMARY_CONTEXT_SIZE,
DEFAULT_SUMMARY_LANGUAGE,
DEFAULT_EMBEDDING_FUNC_MAX_ASYNC,
DEFAULT_EMBEDDING_BATCH_NUM,
DEFAULT_OLLAMA_MODEL_NAME,
DEFAULT_OLLAMA_MODEL_TAG,
DEFAULT_RERANK_BINDING,
DEFAULT_LLM_TIMEOUT,
DEFAULT_EMBEDDING_TIMEOUT,
DEFAULT_RERANK_TIMEOUT,
)
# use the .env that is inside the current folder
# allows to use different .env file for each lightrag instance
# the OS environment variables take precedence over the .env file
load_dotenv(dotenv_path=".env", override=False)
ollama_server_infos = OllamaServerInfos()
DEFAULT_TOKEN_SECRET = "lightrag-jwt-default-secret-key!"
NO_PREFIX_SENTINEL = "NO_PREFIX"
PROVIDER_ASYMMETRIC_EMBEDDING_BINDINGS = {"gemini", "jina", "voyageai"}
PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS = {"azure_openai", "ollama", "openai"}
class DefaultRAGStorageConfig:
KV_STORAGE = "JsonKVStorage"
VECTOR_STORAGE = "NanoVectorDBStorage"
GRAPH_STORAGE = "NetworkXStorage"
DOC_STATUS_STORAGE = "JsonDocStatusStorage"
def get_default_host(binding_type: str) -> str:
default_hosts = {
"ollama": os.getenv("LLM_BINDING_HOST", "http://localhost:11434"),
"lollms": os.getenv("LLM_BINDING_HOST", "http://localhost:9600"),
"azure_openai": os.getenv("AZURE_OPENAI_ENDPOINT", "https://api.openai.com/v1"),
"openai": os.getenv("LLM_BINDING_HOST", "https://api.openai.com/v1"),
# Let boto3 select the regional Bedrock endpoint unless the user
# explicitly overrides LLM_BINDING_HOST / EMBEDDING_BINDING_HOST.
"bedrock": os.getenv("LLM_BINDING_HOST", "DEFAULT_BEDROCK_ENDPOINT"),
# Let google-genai pick the correct default endpoint/version unless the
# user explicitly overrides LLM_BINDING_HOST / EMBEDDING_BINDING_HOST.
"gemini": os.getenv("LLM_BINDING_HOST", "DEFAULT_GEMINI_ENDPOINT"),
}
return default_hosts.get(
binding_type, os.getenv("LLM_BINDING_HOST", "http://localhost:11434")
) # fallback to ollama if unknown
def resolve_asymmetric_embedding_opt_in(
*,
binding: str,
embedding_asymmetric: bool,
embedding_asymmetric_configured: bool,
query_prefix: str | None,
document_prefix: str | None,
query_prefix_configured: bool = False,
document_prefix_configured: bool = False,
) -> bool:
"""Resolve whether query/document-aware embedding behavior should be enabled."""
has_non_empty_prefix = bool(query_prefix or document_prefix)
has_prefix_config = query_prefix_configured or document_prefix_configured
if not embedding_asymmetric:
if has_prefix_config:
state = "false" if embedding_asymmetric_configured else "unset"
logger.warning(
f"EMBEDDING_ASYMMETRIC is {state}; "
"EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX will be ignored."
)
return False
if binding in PROVIDER_ASYMMETRIC_EMBEDDING_BINDINGS:
if has_prefix_config:
logger.warning(
f"{binding} embeddings use provider task parameters for asymmetric "
"mode; EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX will be ignored."
)
return True
if binding in PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS:
if not query_prefix_configured or not document_prefix_configured:
raise ValueError(
f"EMBEDDING_ASYMMETRIC=true for {binding} embeddings requires both "
"EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX. Use "
f"{NO_PREFIX_SENTINEL} for a side that should intentionally have no prefix."
)
if not has_non_empty_prefix:
raise ValueError(
"At least one of EMBEDDING_QUERY_PREFIX or EMBEDDING_DOCUMENT_PREFIX "
f"must be non-empty. Use {NO_PREFIX_SENTINEL} only for the side that "
"should intentionally have no prefix."
)
return True
raise ValueError(
f"EMBEDDING_ASYMMETRIC=true is not supported for {binding} embeddings."
)
def get_embedding_prefix_config(env_key: str) -> tuple[str | None, bool]:
"""Read an embedding prefix and whether it was explicitly configured."""
if env_key not in os.environ:
return None, False
value = os.environ[env_key]
if value == "None":
return None, False
if value == NO_PREFIX_SENTINEL:
return "", True
if value == "":
raise ValueError(
f"{env_key} is empty. Use {NO_PREFIX_SENTINEL} to explicitly request "
"no prefix, or remove the variable to leave it unconfigured."
)
return value, True
def validate_auth_configuration(args: argparse.Namespace) -> None:
"""Reject insecure JWT auth settings before the API starts."""
auth_accounts = (getattr(args, "auth_accounts", "") or "").strip()
token_secret = (getattr(args, "token_secret", "") or "").strip()
if auth_accounts and (not token_secret or token_secret == DEFAULT_TOKEN_SECRET):
raise ValueError(
"TOKEN_SECRET must be explicitly set to a non-default value when AUTH_ACCOUNTS is configured."
)
def _is_set(value: str | None) -> bool:
return bool((value or "").strip())
def validate_bedrock_auth_configuration(args: argparse.Namespace) -> None:
"""Reject Bedrock configuration with no explicit supported auth source."""
bearer_token = os.getenv("AWS_BEARER_TOKEN_BEDROCK")
def has_valid_auth(prefix: str | None = None) -> bool:
if _is_set(bearer_token):
return True
if prefix:
role_access_key = getattr(args, f"{prefix}_aws_access_key_id", None)
role_secret_key = getattr(args, f"{prefix}_aws_secret_access_key", None)
if _is_set(role_access_key) or _is_set(role_secret_key):
return _is_set(role_access_key) and _is_set(role_secret_key)
access_key = getattr(args, "aws_access_key_id", None)
secret_key = getattr(args, "aws_secret_access_key", None)
return _is_set(access_key) and _is_set(secret_key)
if getattr(args, "llm_binding", None) == "bedrock":
if not has_valid_auth():
raise ValueError(
"Bedrock LLM binding requires AWS_ACCESS_KEY_ID and "
"AWS_SECRET_ACCESS_KEY, or process-level AWS_BEARER_TOKEN_BEDROCK."
)
if _is_set(getattr(args, "llm_binding_api_key", None)):
logging.warning(
"LLM_BINDING_API_KEY is set but ignored for Bedrock LLM binding. "
"Use SigV4 AWS_* variables or process-level AWS_BEARER_TOKEN_BEDROCK instead."
)
if getattr(args, "embedding_binding", None) == "bedrock":
if not has_valid_auth():
raise ValueError(
"Bedrock embedding binding requires AWS_ACCESS_KEY_ID and "
"AWS_SECRET_ACCESS_KEY, or process-level AWS_BEARER_TOKEN_BEDROCK."
)
if _is_set(getattr(args, "embedding_binding_api_key", None)):
logging.warning(
"EMBEDDING_BINDING_API_KEY is set but ignored for Bedrock embedding binding. "
"Use SigV4 AWS_* variables or process-level AWS_BEARER_TOKEN_BEDROCK instead."
)
for spec in ROLES:
role = spec.name
if getattr(
args, f"{role}_llm_binding", None
) == "bedrock" and not has_valid_auth(role):
raise ValueError(
f"Bedrock role '{role}' requires {spec.env_prefix}_AWS_ACCESS_KEY_ID "
f"and {spec.env_prefix}_AWS_SECRET_ACCESS_KEY, global "
"AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or process-level "
"AWS_BEARER_TOKEN_BEDROCK."
)
def normalize_binding_name(binding: str | None) -> str | None:
"""Normalize environment-provided binding aliases to canonical names."""
if binding == "aws_bedrock":
return "bedrock"
return binding
def get_binding_env_value(env_key: str, default: str) -> str:
"""Read a binding env var and normalize legacy aliases."""
return normalize_binding_name(get_env_value(env_key, default)) or default
def parse_args() -> argparse.Namespace:
"""
Parse command line arguments with environment variable fallback
Args:
is_uvicorn_mode: Whether running under uvicorn mode
Returns:
argparse.Namespace: Parsed arguments
"""
parser = argparse.ArgumentParser(description="LightRAG API Server")
# Server configuration
parser.add_argument(
"--host",
default=get_env_value("HOST", "0.0.0.0"),
help="Server host (default: from env or 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=get_env_value("PORT", 9621, int),
help="Server port (default: from env or 9621)",
)
# Directory configuration
parser.add_argument(
"--working-dir",
default=get_env_value("WORKING_DIR", "./rag_storage"),
help="Working directory for RAG storage (default: from env or ./rag_storage)",
)
parser.add_argument(
"--input-dir",
default=get_env_value("INPUT_DIR", "./inputs"),
help="Directory containing input documents (default: from env or ./inputs)",
)
parser.add_argument(
"--timeout",
default=get_env_value("TIMEOUT", DEFAULT_TIMEOUT, int, special_none=True),
type=int,
help="Timeout in seconds (useful when using slow AI). Use None for infinite timeout",
)
# RAG configuration
parser.add_argument(
"--max-async",
type=int,
default=get_env_value(
"MAX_ASYNC_LLM", get_env_value("MAX_ASYNC", DEFAULT_MAX_ASYNC, int), int
),
help=f"Maximum async operations (default: from env or {DEFAULT_MAX_ASYNC})",
)
parser.add_argument(
"--summary-max-tokens",
type=int,
default=get_env_value("SUMMARY_MAX_TOKENS", DEFAULT_SUMMARY_MAX_TOKENS, int),
help=f"Maximum token size for entity/relation summary(default: from env or {DEFAULT_SUMMARY_MAX_TOKENS})",
)
parser.add_argument(
"--summary-context-size",
type=int,
default=get_env_value(
"SUMMARY_CONTEXT_SIZE", DEFAULT_SUMMARY_CONTEXT_SIZE, int
),
help=f"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_CONTEXT_SIZE})",
)
parser.add_argument(
"--summary-length-recommended",
type=int,
default=get_env_value(
"SUMMARY_LENGTH_RECOMMENDED", DEFAULT_SUMMARY_LENGTH_RECOMMENDED, int
),
help=f"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_LENGTH_RECOMMENDED})",
)
# Logging configuration
parser.add_argument(
"--log-level",
default=get_env_value("LOG_LEVEL", "INFO"),
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level (default: from env or INFO)",
)
parser.add_argument(
"--verbose",
action="store_true",
default=get_env_value("VERBOSE", False, bool),
help="Enable verbose debug output(only valid for DEBUG log-level)",
)
parser.add_argument(
"--key",
type=str,
default=get_env_value("LIGHTRAG_API_KEY", None),
help="API key for authentication. This protects lightrag server against unauthorized access",
)
# Optional https parameters
parser.add_argument(
"--ssl",
action="store_true",
default=get_env_value("SSL", False, bool),
help="Enable HTTPS (default: from env or False)",
)
parser.add_argument(
"--ssl-certfile",
default=get_env_value("SSL_CERTFILE", None),
help="Path to SSL certificate file (required if --ssl is enabled)",
)
parser.add_argument(
"--ssl-keyfile",
default=get_env_value("SSL_KEYFILE", None),
help="Path to SSL private key file (required if --ssl is enabled)",
)
# Ollama model configuration
parser.add_argument(
"--simulated-model-name",
type=str,
default=get_env_value("OLLAMA_EMULATING_MODEL_NAME", DEFAULT_OLLAMA_MODEL_NAME),
help="Name for the simulated Ollama model (default: from env or lightrag)",
)
parser.add_argument(
"--simulated-model-tag",
type=str,
default=get_env_value("OLLAMA_EMULATING_MODEL_TAG", DEFAULT_OLLAMA_MODEL_TAG),
help="Tag for the simulated Ollama model (default: from env or latest)",
)
# Namespace
parser.add_argument(
"--workspace",
type=str,
default=get_env_value("WORKSPACE", ""),
help="Default workspace for all storage",
)
# Path prefix configuration
parser.add_argument(
"--api-prefix",
type=str,
default=get_env_value("LIGHTRAG_API_PREFIX", ""),
help="API path prefix (e.g., /api/v1). Prepended to all API routes. Default: none (root).",
)
# Server workers configuration
parser.add_argument(
"--workers",
type=int,
default=get_env_value("WORKERS", DEFAULT_WOKERS, int),
help="Number of worker processes (default: from env or 1)",
)
# LLM and embedding bindings
parser.add_argument(
"--llm-binding",
type=str,
default=get_binding_env_value("LLM_BINDING", "ollama"),
choices=[
"lollms",
"ollama",
"openai",
"openai-ollama",
"azure_openai",
"bedrock",
"gemini",
],
help="LLM binding type (default: from env or ollama)",
)
parser.add_argument(
"--embedding-binding",
type=str,
default=get_binding_env_value("EMBEDDING_BINDING", "ollama"),
choices=[
"lollms",
"ollama",
"openai",
"azure_openai",
"bedrock",
"jina",
"gemini",
"voyageai",
],
help="Embedding binding type (default: from env or ollama)",
)
parser.add_argument(
"--rerank-binding",
type=str,
default=get_env_value("RERANK_BINDING", DEFAULT_RERANK_BINDING),
choices=["null", "cohere", "jina", "aliyun"],
help=f"Rerank binding type (default: from env or {DEFAULT_RERANK_BINDING})",
)
# Conditionally add binding-specific options (Ollama, OpenAI, Azure OpenAI, Gemini)
# This registers command line arguments (e.g., --openai-llm-temperature)
# and reads corresponding environment variables (e.g., OPENAI_LLM_TEMPERATURE)
# Determine LLM binding value consistently from command line or environment
llm_binding_value = None
if "--llm-binding" in sys.argv:
try:
idx = sys.argv.index("--llm-binding")
if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"):
llm_binding_value = sys.argv[idx + 1]
except IndexError:
pass
# Fall back to environment variable using same function as argparse default
if llm_binding_value is None:
llm_binding_value = get_binding_env_value("LLM_BINDING", "ollama")
# Add LLM binding options based on determined value
if llm_binding_value == "ollama":
OllamaLLMOptions.add_args(parser)
elif llm_binding_value in ["openai", "azure_openai"]:
OpenAILLMOptions.add_args(parser)
elif llm_binding_value == "gemini":
GeminiLLMOptions.add_args(parser)
elif llm_binding_value == "bedrock":
BedrockLLMOptions.add_args(parser)
# Determine embedding binding value consistently from command line or environment
embedding_binding_value = None
if "--embedding-binding" in sys.argv:
try:
idx = sys.argv.index("--embedding-binding")
if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"):
embedding_binding_value = sys.argv[idx + 1]
except IndexError:
pass
# Fall back to environment variable using same function as argparse default
if embedding_binding_value is None:
embedding_binding_value = get_binding_env_value("EMBEDDING_BINDING", "ollama")
# Add embedding binding options based on determined value
if embedding_binding_value == "ollama":
OllamaEmbeddingOptions.add_args(parser)
elif embedding_binding_value == "gemini":
GeminiEmbeddingOptions.add_args(parser)
args = parser.parse_args()
# convert relative path to absolute path
args.working_dir = os.path.abspath(args.working_dir)
args.input_dir = os.path.abspath(args.input_dir)
# Inject storage configuration from environment variables
args.kv_storage = get_env_value(
"LIGHTRAG_KV_STORAGE", DefaultRAGStorageConfig.KV_STORAGE
)
args.doc_status_storage = get_env_value(
"LIGHTRAG_DOC_STATUS_STORAGE", DefaultRAGStorageConfig.DOC_STATUS_STORAGE
)
args.graph_storage = get_env_value(
"LIGHTRAG_GRAPH_STORAGE", DefaultRAGStorageConfig.GRAPH_STORAGE
)
args.vector_storage = get_env_value(
"LIGHTRAG_VECTOR_STORAGE", DefaultRAGStorageConfig.VECTOR_STORAGE
)
# Get MAX_PARALLEL_INSERT from environment
args.max_parallel_insert = get_env_value(
"MAX_PARALLEL_INSERT", DEFAULT_MAX_PARALLEL_INSERT, int
)
# Get MAX_GRAPH_NODES from environment
args.max_graph_nodes = get_env_value("MAX_GRAPH_NODES", 1000, int)
# Handle openai-ollama special case
if args.llm_binding == "openai-ollama":
args.llm_binding = "openai"
args.embedding_binding = "ollama"
args.llm_binding_host = get_env_value(
"LLM_BINDING_HOST", get_default_host(args.llm_binding)
)
args.embedding_binding_host = get_env_value(
"EMBEDDING_BINDING_HOST", get_default_host(args.embedding_binding)
)
args.llm_binding_api_key = get_env_value("LLM_BINDING_API_KEY", None)
args.embedding_binding_api_key = get_env_value("EMBEDDING_BINDING_API_KEY", "")
args.aws_region = get_env_value("AWS_REGION", None, special_none=True)
args.aws_access_key_id = get_env_value("AWS_ACCESS_KEY_ID", None, special_none=True)
args.aws_secret_access_key = get_env_value(
"AWS_SECRET_ACCESS_KEY", None, special_none=True
)
args.aws_session_token = get_env_value("AWS_SESSION_TOKEN", None, special_none=True)
# Inject model configuration
args.llm_model = get_env_value("LLM_MODEL", "mistral-nemo:latest")
# EMBEDDING_MODEL defaults to None - each binding will use its own default model
# e.g., OpenAI uses "text-embedding-3-small", Jina uses "jina-embeddings-v4"
args.embedding_model = get_env_value("EMBEDDING_MODEL", None, special_none=True)
# EMBEDDING_DIM defaults to None - each binding will use its own default dimension
# Value is inherited from provider defaults via wrap_embedding_func_with_attrs decorator
args.embedding_dim = get_env_value("EMBEDDING_DIM", None, int, special_none=True)
args.embedding_send_dim = get_env_value("EMBEDDING_SEND_DIM", False, bool)
# Inject chunk configuration
args.chunk_size = get_env_value("CHUNK_SIZE", 1200, int)
args.chunk_overlap_size = get_env_value("CHUNK_OVERLAP_SIZE", 100, int)
# Inject LLM cache configuration
# Should not be disabled LLM cache is required for entity/realtion rebuild after file deletion.
args.enable_llm_cache_for_extract = get_env_value(
"ENABLE_LLM_CACHE_FOR_EXTRACT", True, bool
)
args.enable_llm_cache = get_env_value("ENABLE_LLM_CACHE", True, bool)
# --- Per-role LLM configuration (driven by lightrag.ROLES registry) ---
for spec in ROLES:
prefix = spec.env_prefix
attr_prefix = spec.name
binding_key = f"{prefix}_LLM_BINDING"
model_key = f"{prefix}_LLM_MODEL"
host_key = f"{prefix}_LLM_BINDING_HOST"
apikey_key = f"{prefix}_LLM_BINDING_API_KEY"
max_async_key = f"{prefix}_MAX_ASYNC_LLM"
timeout_key = f"{prefix}_LLM_TIMEOUT"
role_binding = normalize_binding_name(
get_env_value(binding_key, None, special_none=True)
)
role_model = get_env_value(model_key, None, special_none=True)
role_host = get_env_value(host_key, None, special_none=True)
role_apikey = get_env_value(apikey_key, None, special_none=True)
role_max_async = get_env_value(max_async_key, None, int, special_none=True)
role_timeout = get_env_value(timeout_key, None, int, special_none=True)
role_aws_region = get_env_value(f"{prefix}_AWS_REGION", None, special_none=True)
role_aws_access_key_id = get_env_value(
f"{prefix}_AWS_ACCESS_KEY_ID", None, special_none=True
)
role_aws_secret_access_key = get_env_value(
f"{prefix}_AWS_SECRET_ACCESS_KEY", None, special_none=True
)
role_aws_session_token = get_env_value(
f"{prefix}_AWS_SESSION_TOKEN", None, special_none=True
)
setattr(args, f"{attr_prefix}_llm_binding", role_binding)
setattr(args, f"{attr_prefix}_llm_model", role_model)
setattr(args, f"{attr_prefix}_llm_binding_host", role_host)
setattr(args, f"{attr_prefix}_llm_binding_api_key", role_apikey)
setattr(args, f"{attr_prefix}_llm_max_async", role_max_async)
setattr(args, f"{attr_prefix}_llm_timeout", role_timeout)
setattr(args, f"{attr_prefix}_aws_region", role_aws_region)
setattr(args, f"{attr_prefix}_aws_access_key_id", role_aws_access_key_id)
setattr(
args, f"{attr_prefix}_aws_secret_access_key", role_aws_secret_access_key
)
setattr(args, f"{attr_prefix}_aws_session_token", role_aws_session_token)
if role_binding == "bedrock" and role_apikey:
raise SystemExit(
f"Bedrock role '{spec.name}' does not support {apikey_key}; use "
"role-specific SigV4 AWS_* variables or process-level "
"AWS_BEARER_TOKEN_BEDROCK."
)
# Cross-provider validation
if role_binding and role_binding != args.llm_binding:
missing = []
if not role_model:
missing.append(model_key)
if not role_host:
role_host = get_default_host(role_binding)
setattr(args, f"{attr_prefix}_llm_binding_host", role_host)
if role_binding != "bedrock" and not role_apikey:
missing.append(apikey_key)
if missing:
raise SystemExit(
f"Cross-provider error for role '{spec.name}': "
f"binding={role_binding} differs from base={args.llm_binding}, "
f"but required env vars are missing: {', '.join(missing)}"
)
# VLM multimodal master switch — when off, the pipeline emits a warning
# and skips every i/t/e item without touching the VLM. When on, the
# effective VLM binding must support image inputs.
args.vlm_process_enable = get_env_value("VLM_PROCESS_ENABLE", False, bool)
if args.vlm_process_enable:
effective_vlm_binding = (
getattr(args, "vlm_llm_binding", None) or args.llm_binding
)
vlm_incompatible = {"lollms"}
if effective_vlm_binding in vlm_incompatible:
raise SystemExit(
f"VLM_PROCESS_ENABLE=true but the effective VLM binding "
f"'{effective_vlm_binding}' does not support image inputs. "
"Configure VLM_LLM_BINDING (or LLM_BINDING) to one of: "
"openai, azure_openai, gemini, bedrock, ollama."
)
# Add environment variables that were previously read directly
args.cors_origins = get_env_value("CORS_ORIGINS", "*")
args.summary_language = get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE)
args.whitelist_paths = get_env_value("WHITELIST_PATHS", "/health,/api/*")
# For JWT Auth
args.auth_accounts = get_env_value("AUTH_ACCOUNTS", "")
args.token_secret = get_env_value("TOKEN_SECRET", None)
args.token_expire_hours = get_env_value("TOKEN_EXPIRE_HOURS", 48, float)
args.guest_token_expire_hours = get_env_value("GUEST_TOKEN_EXPIRE_HOURS", 24, float)
args.jwt_algorithm = get_env_value("JWT_ALGORITHM", "HS256")
# Token auto-renewal configuration (sliding window expiration)
args.token_auto_renew = get_env_value("TOKEN_AUTO_RENEW", True, bool)
args.token_renew_threshold = get_env_value("TOKEN_RENEW_THRESHOLD", 0.5, float)
# Rerank model configuration
args.rerank_model = get_env_value("RERANK_MODEL", None)
args.rerank_binding_host = get_env_value("RERANK_BINDING_HOST", None)
args.rerank_binding_api_key = get_env_value("RERANK_BINDING_API_KEY", None)
# Note: rerank_binding is already set by argparse, no need to override from env
# Min rerank score configuration
args.min_rerank_score = get_env_value(
"MIN_RERANK_SCORE", DEFAULT_MIN_RERANK_SCORE, float
)
# LLM / Embedding request timeouts
args.llm_timeout = get_env_value("LLM_TIMEOUT", DEFAULT_LLM_TIMEOUT, int)
args.embedding_timeout = get_env_value(
"EMBEDDING_TIMEOUT", DEFAULT_EMBEDDING_TIMEOUT, int
)
# Rerank async/timeout configuration (independent from base LLM)
# rerank_max_async falls back to MAX_ASYNC_LLM; rerank_timeout has its own default.
args.rerank_max_async = get_env_value("MAX_ASYNC_RERANK", args.max_async, int)
args.rerank_timeout = get_env_value("RERANK_TIMEOUT", DEFAULT_RERANK_TIMEOUT, int)
# Query configuration
args.top_k = get_env_value("TOP_K", DEFAULT_TOP_K, int)
args.chunk_top_k = get_env_value("CHUNK_TOP_K", DEFAULT_CHUNK_TOP_K, int)
args.max_entity_tokens = get_env_value(
"MAX_ENTITY_TOKENS", DEFAULT_MAX_ENTITY_TOKENS, int
)
args.max_relation_tokens = get_env_value(
"MAX_RELATION_TOKENS", DEFAULT_MAX_RELATION_TOKENS, int
)
args.max_total_tokens = get_env_value(
"MAX_TOTAL_TOKENS", DEFAULT_MAX_TOTAL_TOKENS, int
)
args.cosine_threshold = get_env_value(
"COSINE_THRESHOLD", DEFAULT_COSINE_THRESHOLD, float
)
args.related_chunk_number = get_env_value(
"RELATED_CHUNK_NUMBER", DEFAULT_RELATED_CHUNK_NUMBER, int
)
# Add missing environment variables for health endpoint
args.force_llm_summary_on_merge = get_env_value(
"FORCE_LLM_SUMMARY_ON_MERGE", DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int
)
args.embedding_func_max_async = get_env_value(
"EMBEDDING_FUNC_MAX_ASYNC", DEFAULT_EMBEDDING_FUNC_MAX_ASYNC, int
)
args.embedding_batch_num = get_env_value(
"EMBEDDING_BATCH_NUM", DEFAULT_EMBEDDING_BATCH_NUM, int
)
# Embedding token limit configuration
args.embedding_token_limit = get_env_value(
"EMBEDDING_TOKEN_LIMIT", None, int, special_none=True
)
# File upload size limit (in bytes, None for unlimited)
# Default: 100MB (104857600 bytes)
args.max_upload_size = get_env_value(
"MAX_UPLOAD_SIZE", 104857600, int, special_none=True
)
# Embedding prefix configuration for context-aware embeddings. Empty prefixes
# must be explicit via NO_PREFIX so missing config is distinguishable.
(
args.embedding_document_prefix,
args.embedding_document_prefix_configured,
) = get_embedding_prefix_config("EMBEDDING_DOCUMENT_PREFIX")
(
args.embedding_query_prefix,
args.embedding_query_prefix_configured,
) = get_embedding_prefix_config("EMBEDDING_QUERY_PREFIX")
args.embedding_prefix_no_prefix_sentinel = NO_PREFIX_SENTINEL
args.embedding_prefixes_configured = (
args.embedding_document_prefix_configured
or args.embedding_query_prefix_configured
)
# Asymmetric embedding behavior toggle
args.embedding_asymmetric_configured = "EMBEDDING_ASYMMETRIC" in os.environ
args.embedding_asymmetric = get_env_value("EMBEDDING_ASYMMETRIC", False, bool)
ollama_server_infos.LIGHTRAG_NAME = args.simulated_model_name
ollama_server_infos.LIGHTRAG_TAG = args.simulated_model_tag
# Sanitize workspace: only alphanumeric characters and underscores are allowed
if args.workspace:
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", args.workspace)
if sanitized != args.workspace:
logging.warning(
f"Workspace name '{args.workspace}' contains invalid characters. "
f"It has been sanitized to '{sanitized}'. "
"Only alphanumeric characters and underscores are allowed."
)
args.workspace = sanitized
validate_auth_configuration(args)
validate_bedrock_auth_configuration(args)
return args
def update_uvicorn_mode_config():
# If in uvicorn mode and workers > 1, force it to 1 and log warning
if global_args.workers > 1:
original_workers = global_args.workers
global_args.workers = 1
# Log warning directly here
logging.debug(
f">> Forcing workers=1 in uvicorn mode(Ignoring workers={original_workers})"
)
# Global configuration with lazy initialization
_global_args = None
_initialized = False
def initialize_config(args=None, force=False):
"""Initialize global configuration
This function allows explicit initialization of the configuration,
which is useful for programmatic usage, testing, or embedding LightRAG
in other applications.
Args:
args: Pre-parsed argparse.Namespace or None to parse from sys.argv
force: Force re-initialization even if already initialized
Returns:
argparse.Namespace: The configured arguments
Example:
# Use parsed command line arguments (default)
initialize_config()
# Use custom configuration programmatically
custom_args = argparse.Namespace(
host='localhost',
port=8080,
working_dir='./custom_rag',
# ... other config
)
initialize_config(custom_args)
"""
global _global_args, _initialized
if _initialized and not force:
return _global_args
resolved_args = args if args is not None else parse_args()
validate_auth_configuration(resolved_args)
validate_bedrock_auth_configuration(resolved_args)
_global_args = resolved_args
_initialized = True
return _global_args
def get_config():
"""Get global configuration, auto-initializing if needed
Returns:
argparse.Namespace: The configured arguments
"""
if not _initialized:
initialize_config()
return _global_args
class _GlobalArgsProxy:
"""Proxy object that auto-initializes configuration on first access
This maintains backward compatibility with existing code while
allowing programmatic control over initialization timing.
The proxy fully delegates to the underlying argparse.Namespace,
including support for vars() calls which is used by binding_options
to extract provider-specific configuration options.
"""
def __getattribute__(self, name):
"""Override attribute access to support vars() and regular attribute access.
This method intercepts __dict__ access (used by vars()) and delegates
to the underlying _global_args namespace, ensuring binding options
can be properly extracted.
"""
global _initialized, _global_args
# Handle __dict__ access for vars() support
if name == "__dict__":
if not _initialized:
initialize_config()
return vars(_global_args)
# Handle class-level attributes that should come from the proxy itself
if name in ("__class__", "__repr__", "__getattribute__", "__setattr__"):
return object.__getattribute__(self, name)
# Delegate all other attribute access to the underlying namespace
if not _initialized:
initialize_config()
return getattr(_global_args, name)
def __setattr__(self, name, value):
global _initialized, _global_args
if not _initialized:
initialize_config()
setattr(_global_args, name, value)
def __repr__(self):
global _initialized, _global_args
if not _initialized:
return "<GlobalArgsProxy: Not initialized>"
return repr(_global_args)
# Create proxy instance for backward compatibility
# Existing code like `from config import global_args` continues to work
# The proxy will auto-initialize on first attribute access
global_args = _GlobalArgsProxy()
+162
View File
@@ -0,0 +1,162 @@
# gunicorn_config.py
import os
import logging
from lightrag.kg.shared_storage import finalize_share_data
from lightrag.utils import setup_logger, get_env_value
from lightrag.constants import (
DEFAULT_LOG_MAX_BYTES,
DEFAULT_LOG_BACKUP_COUNT,
DEFAULT_LOG_FILENAME,
)
# Get log directory path from environment variable
log_dir = os.getenv("LOG_DIR", os.getcwd())
log_file_path = os.path.abspath(os.path.join(log_dir, DEFAULT_LOG_FILENAME))
# Ensure log directory exists
os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
# Get log file max size and backup count from environment variables
log_max_bytes = get_env_value("LOG_MAX_BYTES", DEFAULT_LOG_MAX_BYTES, int)
log_backup_count = get_env_value("LOG_BACKUP_COUNT", DEFAULT_LOG_BACKUP_COUNT, int)
# These variables will be set by run_with_gunicorn.py
workers = None
bind = None
loglevel = None
certfile = None
keyfile = None
# Enable preload_app option
preload_app = True
# Use Uvicorn worker
worker_class = "uvicorn.workers.UvicornWorker"
# Other Gunicorn configurations
# Logging configuration
errorlog = os.getenv("ERROR_LOG", log_file_path) # Default write to lightrag.log
accesslog = os.getenv("ACCESS_LOG", log_file_path) # Default write to lightrag.log
logconfig_dict = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"stream": "ext://sys.stdout",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "standard",
"filename": log_file_path,
"maxBytes": log_max_bytes,
"backupCount": log_backup_count,
"encoding": "utf8",
},
},
"filters": {
"path_filter": {
"()": "lightrag.utils.LightragPathFilter",
},
},
"loggers": {
"lightrag": {
"handlers": ["console", "file"],
"level": loglevel.upper() if loglevel else "INFO",
"propagate": False,
},
"gunicorn": {
"handlers": ["console", "file"],
"level": loglevel.upper() if loglevel else "INFO",
"propagate": False,
},
"gunicorn.error": {
"handlers": ["console", "file"],
"level": loglevel.upper() if loglevel else "INFO",
"propagate": False,
},
"gunicorn.access": {
"handlers": ["console", "file"],
"level": loglevel.upper() if loglevel else "INFO",
"propagate": False,
"filters": ["path_filter"],
},
},
}
def on_starting(server):
"""
Executed when Gunicorn starts, before forking the first worker processes
You can use this function to do more initialization tasks for all processes
"""
print("=" * 80)
print(f"GUNICORN MASTER PROCESS: on_starting jobs for {workers} worker(s)")
print(f"Process ID: {os.getpid()}")
print("=" * 80)
# Memory usage monitoring
try:
import psutil
process = psutil.Process(os.getpid())
memory_info = process.memory_info()
msg = (
f"Memory usage after initialization: {memory_info.rss / 1024 / 1024:.2f} MB"
)
print(msg)
except ImportError:
print("psutil not installed, skipping memory usage reporting")
# Log the location of the LightRAG log file
print(f"LightRAG log file: {log_file_path}\n")
print("Gunicorn initialization complete, forking workers...\n")
def on_exit(server):
"""
Executed when Gunicorn is shutting down.
This is a good place to release shared resources.
"""
print("=" * 80)
print("GUNICORN MASTER PROCESS: Shutting down")
print(f"Process ID: {os.getpid()}")
print("Finalizing shared storage...")
finalize_share_data()
print("Gunicorn shutdown complete")
print("=" * 80)
def post_fork(server, worker):
"""
Executed after a worker has been forked.
This is a good place to set up worker-specific configurations.
"""
# Set up main loggers
log_level = loglevel.upper() if loglevel else "INFO"
setup_logger("uvicorn", log_level, add_filter=False, log_file_path=log_file_path)
setup_logger(
"uvicorn.access", log_level, add_filter=True, log_file_path=log_file_path
)
setup_logger("lightrag", log_level, add_filter=True, log_file_path=log_file_path)
# Set up lightrag submodule loggers
for name in logging.root.manager.loggerDict:
if name.startswith("lightrag."):
setup_logger(name, log_level, add_filter=True, log_file_path=log_file_path)
# Disable uvicorn.error logger
uvicorn_error_logger = logging.getLogger("uvicorn.error")
uvicorn_error_logger.handlers = []
uvicorn_error_logger.setLevel(logging.CRITICAL)
uvicorn_error_logger.propagate = False
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import bcrypt
BCRYPT_PASSWORD_PREFIX = "{bcrypt}"
def hash_password(password: str) -> str:
"""Return an AUTH_ACCOUNTS-ready bcrypt password value."""
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
return f"{BCRYPT_PASSWORD_PREFIX}{hashed}"
def verify_password(plain_password: str, stored_password: str) -> bool:
"""Verify a plaintext password against a stored password spec."""
if stored_password.startswith(BCRYPT_PASSWORD_PREFIX):
hashed_password = stored_password[len(BCRYPT_PASSWORD_PREFIX) :]
if not hashed_password:
return False
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
except ValueError:
return False
return stored_password == plain_password
+14
View File
@@ -0,0 +1,14 @@
"""
This module contains all the routers for the LightRAG API.
The document/query/graph routers are intentionally NOT re-exported here:
they are constructed per-app via the `create_*_routes` factory functions
in their respective modules. A module-level singleton would accumulate
duplicate routes if the factory is invoked more than once in the same
process (e.g. across tests), which produced "Duplicate Operation ID"
warnings before the factories were converted to local routers.
"""
from .ollama_api import OllamaAPI
__all__ = ["OllamaAPI"]
File diff suppressed because it is too large Load Diff
+807
View File
@@ -0,0 +1,807 @@
"""
This module contains all graph-related routes for the LightRAG API.
"""
from typing import Optional, Dict, Any
import traceback
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel, Field, field_validator
from lightrag.base import DeletionResult
from lightrag.utils import logger
from ..utils_api import get_combined_auth_dependency
from .document_routes import check_pipeline_busy_or_raise
class EntityUpdateRequest(BaseModel):
entity_name: str
updated_data: Dict[str, Any]
allow_rename: bool = False
allow_merge: bool = False
class RelationUpdateRequest(BaseModel):
source_id: str
target_id: str
updated_data: Dict[str, Any]
class EntityMergeRequest(BaseModel):
entities_to_change: list[str] = Field(
...,
description="List of entity names to be merged and deleted. These are typically duplicate or misspelled entities.",
min_length=1,
examples=[["Elon Msk", "Ellon Musk"]],
)
entity_to_change_into: str = Field(
...,
description="Target entity name that will receive all relationships from the source entities. This entity will be preserved.",
min_length=1,
examples=["Elon Musk"],
)
class EntityCreateRequest(BaseModel):
entity_name: str = Field(
...,
description="Unique name for the new entity",
min_length=1,
examples=["Tesla"],
)
entity_data: Dict[str, Any] = Field(
...,
description="Dictionary containing entity properties. Common fields include 'description' and 'entity_type'.",
examples=[
{
"description": "Electric vehicle manufacturer",
"entity_type": "ORGANIZATION",
}
],
)
class DeleteEntityRequest(BaseModel):
entity_name: str = Field(..., description="The name of the entity to delete.")
@field_validator("entity_name", mode="after")
@classmethod
def validate_entity_name(cls, entity_name: str) -> str:
if not entity_name or not entity_name.strip():
raise ValueError("Entity name cannot be empty")
return entity_name.strip()
class DeleteRelationRequest(BaseModel):
source_entity: str = Field(..., description="The name of the source entity.")
target_entity: str = Field(..., description="The name of the target entity.")
@field_validator("source_entity", "target_entity", mode="after")
@classmethod
def validate_entity_names(cls, entity_name: str) -> str:
if not entity_name or not entity_name.strip():
raise ValueError("Entity name cannot be empty")
return entity_name.strip()
class RelationCreateRequest(BaseModel):
source_entity: str = Field(
...,
description="Name of the source entity. This entity must already exist in the knowledge graph.",
min_length=1,
examples=["Elon Musk"],
)
target_entity: str = Field(
...,
description="Name of the target entity. This entity must already exist in the knowledge graph.",
min_length=1,
examples=["Tesla"],
)
relation_data: Dict[str, Any] = Field(
...,
description="Dictionary containing relationship properties. Common fields include 'description', 'keywords', and 'weight'.",
examples=[
{
"description": "Elon Musk is the CEO of Tesla",
"keywords": "CEO, founder",
"weight": 1.0,
}
],
)
def create_graph_routes(rag, api_key: Optional[str] = None):
# Fresh router per call. A module-level instance would accumulate
# duplicate routes when the factory is invoked more than once in the
# same process (e.g. across tests), which triggers FastAPI's
# "Duplicate Operation ID" warnings.
router = APIRouter(tags=["graph"])
combined_auth = get_combined_auth_dependency(api_key)
@router.get("/graph/label/list", dependencies=[Depends(combined_auth)])
async def get_graph_labels():
"""
Get all graph labels
Returns:
List[str]: List of graph labels
"""
try:
return await rag.get_graph_labels()
except Exception as e:
logger.error(f"Error getting graph labels: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting graph labels: {str(e)}"
)
@router.get("/graph/label/popular", dependencies=[Depends(combined_auth)])
async def get_popular_labels(
limit: int = Query(
300, description="Maximum number of popular labels to return", ge=1, le=1000
),
):
"""
Get popular labels by node degree (most connected entities)
Args:
limit (int): Maximum number of labels to return (default: 300, max: 1000)
Returns:
List[str]: List of popular labels sorted by degree (highest first)
"""
try:
return await rag.chunk_entity_relation_graph.get_popular_labels(limit)
except Exception as e:
logger.error(f"Error getting popular labels: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting popular labels: {str(e)}"
)
@router.get("/graph/label/search", dependencies=[Depends(combined_auth)])
async def search_labels(
q: str = Query(..., description="Search query string"),
limit: int = Query(
50, description="Maximum number of search results to return", ge=1, le=100
),
):
"""
Search labels with fuzzy matching
Args:
q (str): Search query string
limit (int): Maximum number of results to return (default: 50, max: 100)
Returns:
List[str]: List of matching labels sorted by relevance
"""
try:
return await rag.chunk_entity_relation_graph.search_labels(q, limit)
except Exception as e:
logger.error(f"Error searching labels with query '{q}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error searching labels: {str(e)}"
)
@router.get("/graphs", dependencies=[Depends(combined_auth)])
async def get_knowledge_graph(
label: str = Query(..., description="Label to get knowledge graph for"),
max_depth: int = Query(3, description="Maximum depth of graph", ge=1),
max_nodes: int = Query(1000, description="Maximum nodes to return", ge=1),
):
"""
Retrieve a connected subgraph of nodes where the label includes the specified label.
When reducing the number of nodes, the prioritization criteria are as follows:
1. Hops(path) to the staring node take precedence
2. Followed by the degree of the nodes
Args:
label (str): Label of the starting node
max_depth (int, optional): Maximum depth of the subgraph,Defaults to 3
max_nodes: Maxiumu nodes to return
Returns:
Dict[str, List[str]]: Knowledge graph for label
"""
try:
# Log the label parameter to check for leading spaces
logger.debug(
f"get_knowledge_graph called with label: '{label}' (length: {len(label)}, repr: {repr(label)})"
)
return await rag.get_knowledge_graph(
node_label=label,
max_depth=max_depth,
max_nodes=max_nodes,
)
except Exception as e:
logger.error(f"Error getting knowledge graph for label '{label}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting knowledge graph: {str(e)}"
)
@router.get("/graph/entity/exists", dependencies=[Depends(combined_auth)])
async def check_entity_exists(
name: str = Query(..., description="Entity name to check"),
):
"""
Check if an entity with the given name exists in the knowledge graph
Args:
name (str): Name of the entity to check
Returns:
Dict[str, bool]: Dictionary with 'exists' key indicating if entity exists
"""
try:
exists = await rag.chunk_entity_relation_graph.has_node(name)
return {"exists": exists}
except Exception as e:
logger.error(f"Error checking entity existence for '{name}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error checking entity existence: {str(e)}"
)
@router.post("/graph/entity/edit", dependencies=[Depends(combined_auth)])
async def update_entity(request: EntityUpdateRequest):
"""
Update an entity's properties in the knowledge graph
This endpoint allows updating entity properties, including renaming entities.
When renaming to an existing entity name, the behavior depends on allow_merge:
Args:
request (EntityUpdateRequest): Request containing:
- entity_name (str): Name of the entity to update
- updated_data (Dict[str, Any]): Dictionary of properties to update
- allow_rename (bool): Whether to allow entity renaming (default: False)
- allow_merge (bool): Whether to merge into existing entity when renaming
causes name conflict (default: False)
Returns:
Dict with the following structure:
{
"status": "success",
"message": "Entity updated successfully" | "Entity merged successfully into 'target_name'",
"data": {
"entity_name": str, # Final entity name
"description": str, # Entity description
"entity_type": str, # Entity type
"source_id": str, # Source chunk IDs
... # Other entity properties
},
"operation_summary": {
"merged": bool, # Whether entity was merged into another
"merge_status": str, # "success" | "failed" | "not_attempted"
"merge_error": str | None, # Error message if merge failed
"operation_status": str, # "success" | "partial_success" | "failure"
"target_entity": str | None, # Target entity name if renaming/merging
"final_entity": str, # Final entity name after operation
"renamed": bool # Whether entity was renamed
}
}
operation_status values explained:
- "success": All operations completed successfully
* For simple updates: entity properties updated
* For renames: entity renamed successfully
* For merges: non-name updates applied AND merge completed
- "partial_success": Update succeeded but merge failed
* Non-name property updates were applied successfully
* Merge operation failed (entity not merged)
* Original entity still exists with updated properties
* Use merge_error for failure details
- "failure": Operation failed completely
* If merge_status == "failed": Merge attempted but both update and merge failed
* If merge_status == "not_attempted": Regular update failed
* No changes were applied to the entity
merge_status values explained:
- "success": Entity successfully merged into target entity
- "failed": Merge operation was attempted but failed
- "not_attempted": No merge was attempted (normal update/rename)
Behavior when renaming to an existing entity:
- If allow_merge=False: Raises ValueError with 400 status (default behavior)
- If allow_merge=True: Automatically merges the source entity into the existing target entity,
preserving all relationships and applying non-name updates first
Example Request (simple update):
POST /graph/entity/edit
{
"entity_name": "Tesla",
"updated_data": {"description": "Updated description"},
"allow_rename": false,
"allow_merge": false
}
Example Response (simple update success):
{
"status": "success",
"message": "Entity updated successfully",
"data": { ... },
"operation_summary": {
"merged": false,
"merge_status": "not_attempted",
"merge_error": null,
"operation_status": "success",
"target_entity": null,
"final_entity": "Tesla",
"renamed": false
}
}
Example Request (rename with auto-merge):
POST /graph/entity/edit
{
"entity_name": "Elon Msk",
"updated_data": {
"entity_name": "Elon Musk",
"description": "Corrected description"
},
"allow_rename": true,
"allow_merge": true
}
Example Response (merge success):
{
"status": "success",
"message": "Entity merged successfully into 'Elon Musk'",
"data": { ... },
"operation_summary": {
"merged": true,
"merge_status": "success",
"merge_error": null,
"operation_status": "success",
"target_entity": "Elon Musk",
"final_entity": "Elon Musk",
"renamed": true
}
}
Example Response (partial success - update succeeded but merge failed):
{
"status": "success",
"message": "Entity updated successfully",
"data": { ... }, # Data reflects updated "Elon Msk" entity
"operation_summary": {
"merged": false,
"merge_status": "failed",
"merge_error": "Target entity locked by another operation",
"operation_status": "partial_success",
"target_entity": "Elon Musk",
"final_entity": "Elon Msk", # Original entity still exists
"renamed": true
}
}
"""
try:
await check_pipeline_busy_or_raise(rag)
result = await rag.aedit_entity(
entity_name=request.entity_name,
updated_data=request.updated_data,
allow_rename=request.allow_rename,
allow_merge=request.allow_merge,
)
# Extract operation_summary from result, with fallback for backward compatibility
operation_summary = result.get(
"operation_summary",
{
"merged": False,
"merge_status": "not_attempted",
"merge_error": None,
"operation_status": "success",
"target_entity": None,
"final_entity": request.updated_data.get(
"entity_name", request.entity_name
),
"renamed": request.updated_data.get(
"entity_name", request.entity_name
)
!= request.entity_name,
},
)
# Separate entity data from operation_summary for clean response
entity_data = dict(result)
entity_data.pop("operation_summary", None)
# Generate appropriate response message based on merge status
response_message = (
f"Entity merged successfully into '{operation_summary['final_entity']}'"
if operation_summary.get("merged")
else "Entity updated successfully"
)
return {
"status": "success",
"message": response_message,
"data": entity_data,
"operation_summary": operation_summary,
}
except HTTPException:
raise
except ValueError as ve:
logger.error(
f"Validation error updating entity '{request.entity_name}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(f"Error updating entity '{request.entity_name}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error updating entity: {str(e)}"
)
@router.post("/graph/relation/edit", dependencies=[Depends(combined_auth)])
async def update_relation(request: RelationUpdateRequest):
"""Update a relation's properties in the knowledge graph
Args:
request (RelationUpdateRequest): Request containing source ID, target ID and updated data
Returns:
Dict: Updated relation information
"""
try:
await check_pipeline_busy_or_raise(rag)
result = await rag.aedit_relation(
source_entity=request.source_id,
target_entity=request.target_id,
updated_data=request.updated_data,
)
return {
"status": "success",
"message": "Relation updated successfully",
"data": result,
}
except HTTPException:
raise
except ValueError as ve:
logger.error(
f"Validation error updating relation between '{request.source_id}' and '{request.target_id}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(
f"Error updating relation between '{request.source_id}' and '{request.target_id}': {str(e)}"
)
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error updating relation: {str(e)}"
)
@router.post("/graph/entity/create", dependencies=[Depends(combined_auth)])
async def create_entity(request: EntityCreateRequest):
"""
Create a new entity in the knowledge graph
This endpoint creates a new entity node in the knowledge graph with the specified
properties. The system automatically generates vector embeddings for the entity
to enable semantic search and retrieval.
Request Body:
entity_name (str): Unique name identifier for the entity
entity_data (dict): Entity properties including:
- description (str): Textual description of the entity
- entity_type (str): Category/type of the entity (e.g., PERSON, ORGANIZATION, LOCATION)
- source_id (str): Related chunk_id from which the description originates
- Additional custom properties as needed
Response Schema:
{
"status": "success",
"message": "Entity 'Tesla' created successfully",
"data": {
"entity_name": "Tesla",
"description": "Electric vehicle manufacturer",
"entity_type": "ORGANIZATION",
"source_id": "chunk-123<SEP>chunk-456"
... (other entity properties)
}
}
HTTP Status Codes:
200: Entity created successfully
400: Invalid request (e.g., missing required fields, duplicate entity)
500: Internal server error
Example Request:
POST /graph/entity/create
{
"entity_name": "Tesla",
"entity_data": {
"description": "Electric vehicle manufacturer",
"entity_type": "ORGANIZATION"
}
}
"""
try:
await check_pipeline_busy_or_raise(rag)
# Use the proper acreate_entity method which handles:
# - Graph lock for concurrency
# - Vector embedding creation in entities_vdb
# - Metadata population and defaults
# - Index consistency via _edit_entity_done
result = await rag.acreate_entity(
entity_name=request.entity_name,
entity_data=request.entity_data,
)
return {
"status": "success",
"message": f"Entity '{request.entity_name}' created successfully",
"data": result,
}
except HTTPException:
raise
except ValueError as ve:
logger.error(
f"Validation error creating entity '{request.entity_name}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(f"Error creating entity '{request.entity_name}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error creating entity: {str(e)}"
)
@router.post("/graph/relation/create", dependencies=[Depends(combined_auth)])
async def create_relation(request: RelationCreateRequest):
"""
Create a new relationship between two entities in the knowledge graph
This endpoint establishes an undirected relationship between two existing entities.
The provided source/target order is accepted for convenience, but the backend
stored edge is undirected and may be returned with the entities swapped.
Both entities must already exist in the knowledge graph. The system automatically
generates vector embeddings for the relationship to enable semantic search and graph traversal.
Prerequisites:
- Both source_entity and target_entity must exist in the knowledge graph
- Use /graph/entity/create to create entities first if they don't exist
Request Body:
source_entity (str): Name of the source entity (relationship origin)
target_entity (str): Name of the target entity (relationship destination)
relation_data (dict): Relationship properties including:
- description (str): Textual description of the relationship
- keywords (str): Comma-separated keywords describing the relationship type
- source_id (str): Related chunk_id from which the description originates
- weight (float): Relationship strength/importance (default: 1.0)
- Additional custom properties as needed
Response Schema:
{
"status": "success",
"message": "Relation created successfully between 'Elon Musk' and 'Tesla'",
"data": {
"src_id": "Elon Musk",
"tgt_id": "Tesla",
"description": "Elon Musk is the CEO of Tesla",
"keywords": "CEO, founder",
"source_id": "chunk-123<SEP>chunk-456"
"weight": 1.0,
... (other relationship properties)
}
}
HTTP Status Codes:
200: Relationship created successfully
400: Invalid request (e.g., missing entities, invalid data, duplicate relationship)
500: Internal server error
Example Request:
POST /graph/relation/create
{
"source_entity": "Elon Musk",
"target_entity": "Tesla",
"relation_data": {
"description": "Elon Musk is the CEO of Tesla",
"keywords": "CEO, founder",
"weight": 1.0
}
}
"""
try:
await check_pipeline_busy_or_raise(rag)
# Use the proper acreate_relation method which handles:
# - Graph lock for concurrency
# - Entity existence validation
# - Duplicate relation checks
# - Vector embedding creation in relationships_vdb
# - Index consistency via _edit_relation_done
result = await rag.acreate_relation(
source_entity=request.source_entity,
target_entity=request.target_entity,
relation_data=request.relation_data,
)
return {
"status": "success",
"message": f"Relation created successfully between '{request.source_entity}' and '{request.target_entity}'",
"data": result,
}
except HTTPException:
raise
except ValueError as ve:
logger.error(
f"Validation error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(
f"Error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(e)}"
)
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error creating relation: {str(e)}"
)
@router.post("/graph/entities/merge", dependencies=[Depends(combined_auth)])
async def merge_entities(request: EntityMergeRequest):
"""
Merge multiple entities into a single entity, preserving all relationships
This endpoint consolidates duplicate or misspelled entities while preserving the entire
graph structure. It's particularly useful for cleaning up knowledge graphs after document
processing or correcting entity name variations.
What the Merge Operation Does:
1. Deletes the specified source entities from the knowledge graph
2. Transfers all relationships from source entities to the target entity
3. Intelligently merges duplicate relationships (if multiple sources have the same relationship)
4. Updates vector embeddings for accurate retrieval and search
5. Preserves the complete graph structure and connectivity
6. Maintains relationship properties and metadata
Use Cases:
- Fixing spelling errors in entity names (e.g., "Elon Msk" -> "Elon Musk")
- Consolidating duplicate entities discovered after document processing
- Merging name variations (e.g., "NY", "New York", "New York City")
- Cleaning up the knowledge graph for better query performance
- Standardizing entity names across the knowledge base
Request Body:
entities_to_change (list[str]): List of entity names to be merged and deleted
entity_to_change_into (str): Target entity that will receive all relationships
Response Schema:
{
"status": "success",
"message": "Successfully merged 2 entities into 'Elon Musk'",
"data": {
"merged_entity": "Elon Musk",
"deleted_entities": ["Elon Msk", "Ellon Musk"],
"relationships_transferred": 15,
... (merge operation details)
}
}
HTTP Status Codes:
200: Entities merged successfully
400: Invalid request (e.g., empty entity list, target entity doesn't exist)
500: Internal server error
Example Request:
POST /graph/entities/merge
{
"entities_to_change": ["Elon Msk", "Ellon Musk"],
"entity_to_change_into": "Elon Musk"
}
Note:
- The target entity (entity_to_change_into) must exist in the knowledge graph
- Source entities will be permanently deleted after the merge
- This operation cannot be undone, so verify entity names before merging
"""
try:
await check_pipeline_busy_or_raise(rag)
result = await rag.amerge_entities(
source_entities=request.entities_to_change,
target_entity=request.entity_to_change_into,
)
return {
"status": "success",
"message": f"Successfully merged {len(request.entities_to_change)} entities into '{request.entity_to_change_into}'",
"data": result,
}
except HTTPException:
raise
except ValueError as ve:
logger.error(
f"Validation error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(
f"Error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(e)}"
)
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error merging entities: {str(e)}"
)
@router.delete(
"/graph/entity/delete",
response_model=DeletionResult,
dependencies=[Depends(combined_auth)],
)
async def delete_entity(request: DeleteEntityRequest):
"""
Delete an entity and all its relationships from the knowledge graph.
Args:
request (DeleteEntityRequest): The request body containing the entity name.
Returns:
DeletionResult: An object containing the outcome of the deletion process.
Raises:
HTTPException: If the entity is not found (404) or an error occurs (500).
"""
try:
await check_pipeline_busy_or_raise(rag)
result = await rag.adelete_by_entity(entity_name=request.entity_name)
if result.status == "not_found":
raise HTTPException(status_code=404, detail=result.message)
if result.status == "fail":
raise HTTPException(status_code=500, detail=result.message)
# Set doc_id to empty string since this is an entity operation, not document
result.doc_id = ""
return result
except HTTPException:
raise
except Exception as e:
error_msg = f"Error deleting entity '{request.entity_name}': {str(e)}"
logger.error(error_msg)
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=error_msg)
@router.delete(
"/graph/relation/delete",
response_model=DeletionResult,
dependencies=[Depends(combined_auth)],
)
async def delete_relation(request: DeleteRelationRequest):
"""
Delete a relationship between two entities from the knowledge graph.
Args:
request (DeleteRelationRequest): The request body containing the source and target entity names.
Returns:
DeletionResult: An object containing the outcome of the deletion process.
Raises:
HTTPException: If the relation is not found (404) or an error occurs (500).
"""
try:
await check_pipeline_busy_or_raise(rag)
result = await rag.adelete_by_relation(
source_entity=request.source_entity,
target_entity=request.target_entity,
)
if result.status == "not_found":
raise HTTPException(status_code=404, detail=result.message)
if result.status == "fail":
raise HTTPException(status_code=500, detail=result.message)
# Set doc_id to empty string since this is a relation operation, not document
result.doc_id = ""
return result
except HTTPException:
raise
except Exception as e:
error_msg = f"Error deleting relation from '{request.source_entity}' to '{request.target_entity}': {str(e)}"
logger.error(error_msg)
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=error_msg)
return router
+747
View File
@@ -0,0 +1,747 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from typing import List, Dict, Any, Optional, Type
from lightrag.utils import logger
import time
import json
import re
from enum import Enum
from fastapi.responses import StreamingResponse
import asyncio
from lightrag import LightRAG, QueryParam
from lightrag.constants import DEFAULT_QUERY_PRIORITY
from lightrag.utils import TiktokenTokenizer
from lightrag.api.utils_api import get_combined_auth_dependency
from fastapi import Depends
# query mode according to query prefix (bypass is not LightRAG quer mode)
class SearchMode(str, Enum):
naive = "naive"
local = "local"
global_ = "global"
hybrid = "hybrid"
mix = "mix"
bypass = "bypass"
context = "context"
class OllamaMessage(BaseModel):
role: str
content: str
images: Optional[List[str]] = None
class OllamaChatRequest(BaseModel):
model: str
messages: List[OllamaMessage]
stream: bool = True
options: Optional[Dict[str, Any]] = None
system: Optional[str] = None
class OllamaChatResponse(BaseModel):
model: str
created_at: str
message: OllamaMessage
done: bool
class OllamaGenerateRequest(BaseModel):
model: str
prompt: str
system: Optional[str] = None
stream: bool = False
options: Optional[Dict[str, Any]] = None
class OllamaGenerateResponse(BaseModel):
model: str
created_at: str
response: str
done: bool
context: Optional[List[int]]
total_duration: Optional[int]
load_duration: Optional[int]
prompt_eval_count: Optional[int]
prompt_eval_duration: Optional[int]
eval_count: Optional[int]
eval_duration: Optional[int]
class OllamaVersionResponse(BaseModel):
version: str
class OllamaModelDetails(BaseModel):
parent_model: str
format: str
family: str
families: List[str]
parameter_size: str
quantization_level: str
class OllamaModel(BaseModel):
name: str
model: str
size: int
digest: str
modified_at: str
details: OllamaModelDetails
class OllamaTagResponse(BaseModel):
models: List[OllamaModel]
class OllamaRunningModelDetails(BaseModel):
parent_model: str
format: str
family: str
families: List[str]
parameter_size: str
quantization_level: str
class OllamaRunningModel(BaseModel):
name: str
model: str
size: int
digest: str
details: OllamaRunningModelDetails
expires_at: str
size_vram: int
class OllamaPsResponse(BaseModel):
models: List[OllamaRunningModel]
async def parse_request_body(
request: Request, model_class: Type[BaseModel]
) -> BaseModel:
"""
Parse request body based on Content-Type header.
Supports both application/json and application/octet-stream.
Args:
request: The FastAPI Request object
model_class: The Pydantic model class to parse the request into
Returns:
An instance of the provided model_class
"""
content_type = request.headers.get("content-type", "").lower()
try:
if content_type.startswith("application/json"):
# FastAPI already handles JSON parsing for us
body = await request.json()
elif content_type.startswith("application/octet-stream"):
# Manually parse octet-stream as JSON
body_bytes = await request.body()
body = json.loads(body_bytes.decode("utf-8"))
else:
# Try to parse as JSON for any other content type
body_bytes = await request.body()
body = json.loads(body_bytes.decode("utf-8"))
# Create an instance of the model
return model_class(**body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON in request body")
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Error parsing request body: {str(e)}"
)
def estimate_tokens(text: str) -> int:
"""Estimate the number of tokens in text using tiktoken"""
tokens = TiktokenTokenizer().encode(text)
return len(tokens)
def parse_query_mode(query: str) -> tuple[str, SearchMode, bool, Optional[str]]:
"""Parse query prefix to determine search mode
Returns tuple of (cleaned_query, search_mode, only_need_context, user_prompt)
Examples:
- "/local[use mermaid format for diagrams] query string" -> (cleaned_query, SearchMode.local, False, "use mermaid format for diagrams")
- "/[use mermaid format for diagrams] query string" -> (cleaned_query, SearchMode.hybrid, False, "use mermaid format for diagrams")
- "/local query string" -> (cleaned_query, SearchMode.local, False, None)
"""
# Initialize user_prompt as None
user_prompt = None
# First check if there's a bracket format for user prompt
bracket_pattern = r"^/([a-z]*)\[(.*?)\](.*)"
bracket_match = re.match(bracket_pattern, query)
if bracket_match:
mode_prefix = bracket_match.group(1)
user_prompt = bracket_match.group(2)
remaining_query = bracket_match.group(3).lstrip()
# Reconstruct query, removing the bracket part
query = f"/{mode_prefix} {remaining_query}".strip()
# Unified handling of mode and only_need_context determination
mode_map = {
"/local ": (SearchMode.local, False),
"/global ": (
SearchMode.global_,
False,
), # global_ is used because 'global' is a Python keyword
"/naive ": (SearchMode.naive, False),
"/hybrid ": (SearchMode.hybrid, False),
"/mix ": (SearchMode.mix, False),
"/bypass ": (SearchMode.bypass, False),
"/context": (
SearchMode.mix,
True,
),
"/localcontext": (SearchMode.local, True),
"/globalcontext": (SearchMode.global_, True),
"/hybridcontext": (SearchMode.hybrid, True),
"/naivecontext": (SearchMode.naive, True),
"/mixcontext": (SearchMode.mix, True),
}
for prefix, (mode, only_need_context) in mode_map.items():
if query.startswith(prefix):
# After removing prefix and leading spaces
cleaned_query = query[len(prefix) :].lstrip()
return cleaned_query, mode, only_need_context, user_prompt
return query, SearchMode.mix, False, user_prompt
class OllamaAPI:
def __init__(self, rag: LightRAG, top_k: int = 60, api_key: Optional[str] = None):
self.rag = rag
self.ollama_server_infos = rag.ollama_server_infos
self.top_k = top_k
self.api_key = api_key
self.router = APIRouter(tags=["ollama"])
self.setup_routes()
def setup_routes(self):
# Create combined auth dependency for Ollama API routes
combined_auth = get_combined_auth_dependency(self.api_key)
@self.router.get("/version", dependencies=[Depends(combined_auth)])
async def get_version():
"""Get Ollama version information"""
return OllamaVersionResponse(version="0.9.3")
@self.router.get("/tags", dependencies=[Depends(combined_auth)])
async def get_tags():
"""Return available models acting as an Ollama server"""
return OllamaTagResponse(
models=[
{
"name": self.ollama_server_infos.LIGHTRAG_MODEL,
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"modified_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"size": self.ollama_server_infos.LIGHTRAG_SIZE,
"digest": self.ollama_server_infos.LIGHTRAG_DIGEST,
"details": {
"parent_model": "",
"format": "gguf",
"family": self.ollama_server_infos.LIGHTRAG_NAME,
"families": [self.ollama_server_infos.LIGHTRAG_NAME],
"parameter_size": "13B",
"quantization_level": "Q4_0",
},
}
]
)
@self.router.get("/ps", dependencies=[Depends(combined_auth)])
async def get_running_models():
"""List Running Models - returns currently running models"""
return OllamaPsResponse(
models=[
{
"name": self.ollama_server_infos.LIGHTRAG_MODEL,
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"size": self.ollama_server_infos.LIGHTRAG_SIZE,
"digest": self.ollama_server_infos.LIGHTRAG_DIGEST,
"details": {
"parent_model": "",
"format": "gguf",
"family": "llama",
"families": ["llama"],
"parameter_size": "7.2B",
"quantization_level": "Q4_0",
},
"expires_at": "2050-12-31T14:38:31.83753-07:00",
"size_vram": self.ollama_server_infos.LIGHTRAG_SIZE,
}
]
)
@self.router.post(
"/generate", dependencies=[Depends(combined_auth)], include_in_schema=True
)
async def generate(raw_request: Request):
"""Handle generate completion requests acting as an Ollama model
For compatibility purpose, the request is not processed by LightRAG,
and will be handled by underlying LLM model.
Supports both application/json and application/octet-stream Content-Types.
"""
try:
# Parse the request body manually
request = await parse_request_body(raw_request, OllamaGenerateRequest)
query = request.prompt
start_time = time.time_ns()
prompt_tokens = estimate_tokens(query)
role_kwargs = (
dict(self.rag.role_llm_kwargs["query"])
if self.rag.role_llm_kwargs["query"] is not None
else dict(self.rag.llm_model_kwargs)
)
if request.system:
role_kwargs["system_prompt"] = request.system
if request.stream:
response = await (self.rag.role_llm_funcs["query"])(
query,
stream=True,
_priority=DEFAULT_QUERY_PRIORITY,
**role_kwargs,
)
async def stream_generator():
first_chunk_time = None
last_chunk_time = time.time_ns()
total_response = ""
# Ensure response is an async generator
if isinstance(response, str):
# If it's a string, send in two parts
first_chunk_time = start_time
last_chunk_time = time.time_ns()
total_response = response
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": response,
"done": False,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
completion_tokens = estimate_tokens(total_response)
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": "",
"done": True,
"done_reason": "stop",
"context": [],
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
else:
try:
async for chunk in response:
if chunk:
if first_chunk_time is None:
first_chunk_time = time.time_ns()
last_chunk_time = time.time_ns()
total_response += chunk
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": chunk,
"done": False,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
except (asyncio.CancelledError, Exception) as e:
error_msg = str(e)
if isinstance(e, asyncio.CancelledError):
error_msg = "Stream was cancelled by server"
else:
error_msg = f"Provider error: {error_msg}"
logger.error(f"Stream error: {error_msg}")
# Send error message to client
error_data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": f"\n\nError: {error_msg}",
"error": f"\n\nError: {error_msg}",
"done": False,
}
yield f"{json.dumps(error_data, ensure_ascii=False)}\n"
# Send final message to close the stream
final_data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": "",
"done": True,
}
yield f"{json.dumps(final_data, ensure_ascii=False)}\n"
return
if first_chunk_time is None:
first_chunk_time = start_time
completion_tokens = estimate_tokens(total_response)
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": "",
"done": True,
"done_reason": "stop",
"context": [],
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
return
return StreamingResponse(
stream_generator(),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/x-ndjson",
"X-Accel-Buffering": "no", # Ensure proper handling of streaming responses in Nginx proxy
},
)
else:
first_chunk_time = time.time_ns()
response_text = await (self.rag.role_llm_funcs["query"])(
query,
stream=False,
_priority=DEFAULT_QUERY_PRIORITY,
**role_kwargs,
)
last_chunk_time = time.time_ns()
if not response_text:
response_text = "No response generated"
completion_tokens = estimate_tokens(str(response_text))
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
return {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": str(response_text),
"done": True,
"done_reason": "stop",
"context": [],
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
except Exception as e:
logger.error(f"Ollama generate error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@self.router.post(
"/chat", dependencies=[Depends(combined_auth)], include_in_schema=True
)
async def chat(raw_request: Request):
"""Process chat completion requests by acting as an Ollama model.
Routes user queries through LightRAG by selecting query mode based on query prefix.
Detects and forwards OpenWebUI session-related requests (for meta data generation task) directly to LLM.
Supports both application/json and application/octet-stream Content-Types.
"""
try:
# Parse the request body manually
request = await parse_request_body(raw_request, OllamaChatRequest)
# Get all messages
messages = request.messages
if not messages:
raise HTTPException(status_code=400, detail="No messages provided")
# Validate that the last message is from a user
if messages[-1].role != "user":
raise HTTPException(
status_code=400, detail="Last message must be from user role"
)
# Get the last message as query and previous messages as history
query = messages[-1].content
# Convert OllamaMessage objects to dictionaries
conversation_history = [
{"role": msg.role, "content": msg.content} for msg in messages[:-1]
]
# Check for query prefix
cleaned_query, mode, only_need_context, user_prompt = parse_query_mode(
query
)
start_time = time.time_ns()
prompt_tokens = estimate_tokens(cleaned_query)
param_dict = {
"mode": mode.value,
"stream": request.stream,
"only_need_context": only_need_context,
"conversation_history": conversation_history,
"top_k": self.top_k,
}
# Add user_prompt to param_dict
if user_prompt is not None:
param_dict["user_prompt"] = user_prompt
query_param = QueryParam(**param_dict)
if request.stream:
# Determine if the request is prefix with "/bypass"
if mode == SearchMode.bypass:
role_kwargs = (
dict(self.rag.role_llm_kwargs["query"])
if self.rag.role_llm_kwargs["query"] is not None
else dict(self.rag.llm_model_kwargs)
)
if request.system:
role_kwargs["system_prompt"] = request.system
response = await (self.rag.role_llm_funcs["query"])(
cleaned_query,
stream=True,
history_messages=conversation_history,
_priority=DEFAULT_QUERY_PRIORITY,
**role_kwargs,
)
else:
response = await self.rag.aquery(
cleaned_query, param=query_param
)
async def stream_generator():
first_chunk_time = None
last_chunk_time = time.time_ns()
total_response = ""
# Ensure response is an async generator
if isinstance(response, str):
# If it's a string, send in two parts
first_chunk_time = start_time
last_chunk_time = time.time_ns()
total_response = response
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": response,
"images": None,
},
"done": False,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
completion_tokens = estimate_tokens(total_response)
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": "",
"images": None,
},
"done_reason": "stop",
"done": True,
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
else:
try:
async for chunk in response:
if chunk:
if first_chunk_time is None:
first_chunk_time = time.time_ns()
last_chunk_time = time.time_ns()
total_response += chunk
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": chunk,
"images": None,
},
"done": False,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
except (asyncio.CancelledError, Exception) as e:
error_msg = str(e)
if isinstance(e, asyncio.CancelledError):
error_msg = "Stream was cancelled by server"
else:
error_msg = f"Provider error: {error_msg}"
logger.error(f"Stream error: {error_msg}")
# Send error message to client
error_data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": f"\n\nError: {error_msg}",
"images": None,
},
"error": f"\n\nError: {error_msg}",
"done": False,
}
yield f"{json.dumps(error_data, ensure_ascii=False)}\n"
# Send final message to close the stream
final_data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": "",
"images": None,
},
"done": True,
}
yield f"{json.dumps(final_data, ensure_ascii=False)}\n"
return
if first_chunk_time is None:
first_chunk_time = start_time
completion_tokens = estimate_tokens(total_response)
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": "",
"images": None,
},
"done_reason": "stop",
"done": True,
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
return StreamingResponse(
stream_generator(),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/x-ndjson",
"X-Accel-Buffering": "no", # Ensure proper handling of streaming responses in Nginx proxy
},
)
else:
first_chunk_time = time.time_ns()
# Determine if the request is prefix with "/bypass" or from Open WebUI's session title and session keyword generation task
match_result = re.search(
r"\n<chat_history>\nUSER:", cleaned_query, re.MULTILINE
)
if match_result or mode == SearchMode.bypass:
role_kwargs = (
dict(self.rag.role_llm_kwargs["query"])
if self.rag.role_llm_kwargs["query"] is not None
else dict(self.rag.llm_model_kwargs)
)
if request.system:
role_kwargs["system_prompt"] = request.system
response_text = await (self.rag.role_llm_funcs["query"])(
cleaned_query,
stream=False,
history_messages=conversation_history,
_priority=DEFAULT_QUERY_PRIORITY,
**role_kwargs,
)
else:
response_text = await self.rag.aquery(
cleaned_query, param=query_param
)
last_chunk_time = time.time_ns()
if not response_text:
response_text = "No response generated"
completion_tokens = estimate_tokens(str(response_text))
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
return {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": str(response_text),
"images": None,
},
"done_reason": "stop",
"done": True,
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
except Exception as e:
logger.error(f"Ollama chat error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
File diff suppressed because it is too large Load Diff
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env python
"""
Start LightRAG server with Gunicorn
"""
import os
import sys
import platform
import pipmaster as pm
# Capture this before importing LightRAG modules, because those imports load .env.
# On macOS, libobjc needs this value in the inherited process environment.
_PROCESS_START_OBJC_FORK_SAFETY = os.environ.get("OBJC_DISABLE_INITIALIZE_FORK_SAFETY")
def check_and_install_dependencies():
"""Check and install required dependencies"""
required_packages = [
"gunicorn",
"tiktoken",
"psutil",
# Add other required packages here
]
for package in required_packages:
if not pm.is_installed(package):
print(f"Installing {package}...")
pm.install(package)
print(f"{package} installed successfully")
def _build_global_concurrency_limits(args) -> dict:
"""Derive cross-worker concurrency limits from the MAX_ASYNC settings.
Under gunicorn multi-worker, every MAX_ASYNC value keeps its documented
meaning — the maximum number of concurrent provider calls — by acting
BOTH as each worker's local limit and as the cross-worker global cap
(without the gate the real total would be ~ MAX_ASYNC x workers).
Group names must match the ``concurrency_group`` values passed to
``priority_limit_async_func_call``: ``llm:{role}`` for the LLM roles,
plus ``embedding`` and ``rerank``. Role fallbacks mirror the runtime
resolution in ``_get_effective_role_llm_max_async``.
"""
from lightrag.llm_roles import ROLES
limits = {}
for spec in ROLES:
role_limit = getattr(args, f"{spec.name}_llm_max_async", None)
if role_limit is None:
role_limit = args.max_async
if role_limit is not None and role_limit > 0:
limits[f"llm:{spec.name}"] = role_limit
embedding_limit = getattr(args, "embedding_func_max_async", None)
if embedding_limit is not None and embedding_limit > 0:
limits["embedding"] = embedding_limit
rerank_limit = getattr(args, "rerank_max_async", None)
if rerank_limit is not None and rerank_limit > 0:
limits["rerank"] = rerank_limit
return limits
def main():
from lightrag.api.utils_api import display_splash_screen, check_env_file
from lightrag.api.config import global_args, initialize_config
from lightrag.utils import get_env_value
from lightrag.kg.shared_storage import initialize_share_data
from lightrag.constants import (
DEFAULT_WOKERS,
DEFAULT_TIMEOUT,
)
# Explicitly initialize configuration for Gunicorn mode
initialize_config()
# Set Gunicorn mode flag for lifespan cleanup detection
os.environ["LIGHTRAG_GUNICORN_MODE"] = "1"
# Check .env file
if not check_env_file():
sys.exit(1)
# Check macOS fork safety environment variable for multi-worker mode
if (
platform.system() == "Darwin"
and global_args.workers > 1
and _PROCESS_START_OBJC_FORK_SAFETY != "YES"
):
current_objc_fork_safety = os.environ.get("OBJC_DISABLE_INITIALIZE_FORK_SAFETY")
print("\n" + "=" * 80)
print("❌ ERROR: Missing required environment variable on macOS!")
print("=" * 80)
print("\nmacOS with Gunicorn multi-worker mode requires:")
print(" OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES")
print("\nReason:")
print(" NumPy uses macOS's Accelerate framework (Objective-C based) for")
print(" vector computations. The Objective-C runtime has fork safety checks")
print(" that will crash worker processes when embedding functions are called.")
print("\nCurrent configuration:")
print(" - Operating System: macOS (Darwin)")
print(f" - Workers: {global_args.workers}")
print(
" - Process Environment at Startup: "
f"{_PROCESS_START_OBJC_FORK_SAFETY or 'NOT SET'}"
)
print(
f" - Environment After .env Load: {current_objc_fork_safety or 'NOT SET'}"
)
if current_objc_fork_safety == "YES":
print("\nNote:")
print(" OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES was loaded from .env,")
print(" but that is too late for the macOS Objective-C runtime.")
print(" Export it before starting lightrag-gunicorn.")
print("\nHow to fix:")
print(" Option 1 - Set environment variable before starting (recommended):")
print(" export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES")
print(" lightrag-gunicorn --workers 2")
print("\n Option 2 - Add to your shell profile (~/.zshrc or ~/.bash_profile):")
print(" echo 'export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES' >> ~/.zshrc")
print(" source ~/.zshrc")
print("\n Option 3 - Use single worker mode (no multiprocessing):")
print(" lightrag-server --workers 1")
print("=" * 80 + "\n")
sys.exit(1)
# Check and install dependencies
check_and_install_dependencies()
# Note: Signal handlers are NOT registered here because:
# - Master cleanup already handled by gunicorn_config.on_exit()
# Display startup information
display_splash_screen(global_args)
print("🚀 Starting LightRAG with Gunicorn")
print(f"🔄 Worker management: Gunicorn (workers={global_args.workers})")
print("🔍 Preloading app: Enabled")
print("📝 Note: Using Gunicorn's preload feature for shared data initialization")
print("\n\n" + "=" * 80)
print("MAIN PROCESS INITIALIZATION")
print(f"Process ID: {os.getpid()}")
print(f"Workers setting: {global_args.workers}")
print("=" * 80 + "\n")
# Import Gunicorn's StandaloneApplication
from gunicorn.app.base import BaseApplication
# Define a custom application class that loads our config
class GunicornApp(BaseApplication):
def __init__(self, app, options=None):
self.options = options or {}
self.application = app
super().__init__()
def load_config(self):
# Define valid Gunicorn configuration options
valid_options = {
"bind",
"workers",
"worker_class",
"timeout",
"keepalive",
"preload_app",
"errorlog",
"accesslog",
"loglevel",
"certfile",
"keyfile",
"limit_request_line",
"limit_request_fields",
"limit_request_field_size",
"graceful_timeout",
"max_requests",
"max_requests_jitter",
}
# Special hooks that need to be set separately
special_hooks = {
"on_starting",
"on_reload",
"on_exit",
"pre_fork",
"post_fork",
"pre_exec",
"pre_request",
"post_request",
"worker_init",
"worker_exit",
"nworkers_changed",
"child_exit",
}
# Import and configure the gunicorn_config module
from lightrag.api import gunicorn_config
# Set configuration variables in gunicorn_config, prioritizing command line arguments
gunicorn_config.workers = (
global_args.workers
if global_args.workers
else get_env_value("WORKERS", DEFAULT_WOKERS, int)
)
# Bind configuration prioritizes command line arguments
host = (
global_args.host
if global_args.host != "0.0.0.0"
else os.getenv("HOST", "0.0.0.0")
)
port = (
global_args.port
if global_args.port != 9621
else get_env_value("PORT", 9621, int)
)
gunicorn_config.bind = f"{host}:{port}"
# Log level configuration prioritizes command line arguments
gunicorn_config.loglevel = (
global_args.log_level.lower()
if global_args.log_level
else os.getenv("LOG_LEVEL", "info")
)
# Timeout configuration prioritizes command line arguments
gunicorn_config.timeout = (
global_args.timeout + 30
if global_args.timeout is not None
else get_env_value(
"TIMEOUT", DEFAULT_TIMEOUT + 30, int, special_none=True
)
)
# Keepalive configuration
gunicorn_config.keepalive = get_env_value("KEEPALIVE", 5, int)
# SSL configuration prioritizes command line arguments
if global_args.ssl or os.getenv("SSL", "").lower() in (
"true",
"1",
"yes",
"t",
"on",
):
gunicorn_config.certfile = (
global_args.ssl_certfile
if global_args.ssl_certfile
else os.getenv("SSL_CERTFILE")
)
gunicorn_config.keyfile = (
global_args.ssl_keyfile
if global_args.ssl_keyfile
else os.getenv("SSL_KEYFILE")
)
# Set configuration options from the module
for key in dir(gunicorn_config):
if key in valid_options:
value = getattr(gunicorn_config, key)
# Skip functions like on_starting and None values
if not callable(value) and value is not None:
self.cfg.set(key, value)
# Set special hooks
elif key in special_hooks:
value = getattr(gunicorn_config, key)
if callable(value):
self.cfg.set(key, value)
if hasattr(gunicorn_config, "logconfig_dict"):
self.cfg.set(
"logconfig_dict", getattr(gunicorn_config, "logconfig_dict")
)
def load(self):
# Import the application
from lightrag.api.lightrag_server import get_application
return get_application(global_args)
# Create the application
app = GunicornApp("")
# Force workers to be an integer and greater than 1 for multi-process mode
workers_count = global_args.workers
if workers_count > 1:
# Set a flag to indicate we're in the main process
os.environ["LIGHTRAG_MAIN_PROCESS"] = "1"
# Cross-worker global concurrency limits derived from MAX_ASYNC
# (read-only after this point; forked workers inherit them as
# module globals). Single-worker mode needs no cross-process gate —
# the per-process max_async already IS the total limit there.
initialize_share_data(
workers_count,
global_concurrency_limits=_build_global_concurrency_limits(global_args),
)
else:
initialize_share_data(1)
# Run the application
print("\nStarting Gunicorn with direct Python API...")
app.run()
if __name__ == "__main__":
main()
+128
View File
@@ -0,0 +1,128 @@
"""Helpers for validating startup runtime expectations from `.env`."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import dotenv_values
_CONTAINER_RUNTIME_TARGETS = {"compose", "docker"}
@dataclass(frozen=True)
class RuntimeEnvironment:
"""Describes whether the current process is running in a container runtime."""
in_container: bool
in_docker: bool
in_kubernetes: bool
@property
def label(self) -> str:
if self.in_kubernetes:
return "Kubernetes"
if self.in_docker:
return "Docker"
return "host"
def _read_cgroup_content() -> str:
"""Best-effort read of cgroup metadata for container detection."""
for candidate in ("/proc/1/cgroup", "/proc/self/cgroup"):
try:
return Path(candidate).read_text(encoding="utf-8")
except OSError:
continue
return ""
def detect_runtime_environment(
environ: dict[str, str] | None = None,
) -> RuntimeEnvironment:
"""Detect whether the current process is running on host, Docker, or Kubernetes."""
environ = environ or os.environ
cgroup_content = _read_cgroup_content().lower()
in_kubernetes = bool(
environ.get("KUBERNETES_SERVICE_HOST")
or Path("/var/run/secrets/kubernetes.io/serviceaccount").exists()
or "kubepods" in cgroup_content
or "kubernetes" in cgroup_content
)
in_docker = bool(
Path("/.dockerenv").exists()
or Path("/run/.containerenv").exists()
or any(
marker in cgroup_content
for marker in ("docker", "containerd", "libpod", "podman")
)
)
return RuntimeEnvironment(
in_container=in_kubernetes or in_docker,
in_docker=in_docker,
in_kubernetes=in_kubernetes,
)
def load_runtime_target_from_env_file(env_path: str | Path = ".env") -> str | None:
"""Return the raw LIGHTRAG_RUNTIME_TARGET value from the `.env` file, if present."""
env_values = dotenv_values(str(env_path))
runtime_target = env_values.get("LIGHTRAG_RUNTIME_TARGET")
if runtime_target is None:
return None
return runtime_target.strip()
def validate_runtime_target(
runtime_target: str | None,
runtime_environment: RuntimeEnvironment | None = None,
) -> tuple[bool, str | None]:
"""Validate `.env` runtime target against the current runtime environment."""
if runtime_target is None:
return True, None
normalized_target = runtime_target.strip().lower()
runtime_environment = runtime_environment or detect_runtime_environment()
if normalized_target == "host":
if runtime_environment.in_container:
return (
False,
"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET=host.\n"
"This value from .env requires the server process to run on the host, "
f"but the current process is running inside {runtime_environment.label}.",
)
return True, None
if normalized_target in _CONTAINER_RUNTIME_TARGETS:
if runtime_environment.in_container:
return True, None
return (
False,
f"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target}.\n"
"This value from .env requires the server process to run inside Docker or "
"Kubernetes, but the current process is running on the host.",
)
return (
False,
f"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target!r}.\n"
"This value from .env must be 'host' or 'compose' (alias: 'docker').",
)
def validate_runtime_target_from_env_file(
env_path: str | Path = ".env",
runtime_environment: RuntimeEnvironment | None = None,
) -> tuple[bool, str | None]:
"""Load LIGHTRAG_RUNTIME_TARGET from `.env` and validate it if declared."""
runtime_target = load_runtime_target_from_env_file(env_path)
return validate_runtime_target(runtime_target, runtime_environment)
Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+575
View File
@@ -0,0 +1,575 @@
"""
Utility functions for the LightRAG API.
"""
import os
import argparse
from typing import Optional, List, Tuple
import sys
import time
import logging
from ascii_colors import ASCIIColors
from .._version import __api_version__ as api_version
from .._version import __version__ as core_version
from lightrag.constants import (
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
)
from lightrag.api.runtime_validation import validate_runtime_target_from_env_file
from fastapi import HTTPException, Security, Request, Response, status
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from starlette.status import HTTP_403_FORBIDDEN
from .auth import auth_handler
from .config import ollama_server_infos, global_args, get_env_value
logger = logging.getLogger("lightrag")
# ========== Token Renewal Rate Limiting ==========
# Cache to track last renewal time per user (username as key)
# Format: {username: last_renewal_timestamp}
_token_renewal_cache: dict[str, float] = {}
_RENEWAL_MIN_INTERVAL = 60 # Minimum 60 seconds between renewals for same user
# ========== Token Renewal Path Exclusions ==========
# Paths that should NOT trigger token auto-renewal
# - /health: Health check endpoint, no login required
# - /documents/paginated: Client polls this frequently (5-30s), renewal not needed
# - /documents/pipeline_status: Client polls this very frequently (2s), renewal not needed
_TOKEN_RENEWAL_SKIP_PATHS = [
"/health",
"/documents/paginated",
"/documents/pipeline_status",
]
def check_env_file():
"""
Check if .env file exists and handle user confirmation if needed.
Returns True if should continue, False if should exit.
"""
env_path = ".env"
if not os.path.exists(env_path):
warning_msg = "Warning: Startup directory must contain .env file for multi-instance support."
ASCIIColors.yellow(warning_msg)
# Check if running in interactive terminal
if sys.stdin.isatty():
response = input("Do you want to continue? (yes/NO): ")
if response.lower() != "yes":
ASCIIColors.red("Server startup cancelled")
return False
return True
is_valid, error_message = validate_runtime_target_from_env_file(env_path)
if not is_valid:
for line in error_message.splitlines():
ASCIIColors.red(line)
return False
return True
# Get whitelist paths from global_args, only once during initialization
whitelist_paths = global_args.whitelist_paths.split(",")
# Pre-compile path matching patterns
whitelist_patterns: List[Tuple[str, bool]] = []
for path in whitelist_paths:
path = path.strip()
if path:
# If path ends with /*, match all paths with that prefix
if path.endswith("/*"):
prefix = path[:-2]
whitelist_patterns.append((prefix, True)) # (prefix, is_prefix_match)
else:
whitelist_patterns.append((path, False)) # (exact_path, is_prefix_match)
# Global authentication configuration
auth_configured = bool(auth_handler.accounts)
def get_combined_auth_dependency(api_key: Optional[str] = None):
"""
Create a combined authentication dependency that implements authentication logic
based on API key, OAuth2 token, and whitelist paths.
Args:
api_key (Optional[str]): API key for validation
Returns:
Callable: A dependency function that implements the authentication logic
"""
# Use global whitelist_patterns and auth_configured variables
# whitelist_patterns and auth_configured are already initialized at module level
# Only calculate api_key_configured as it depends on the function parameter
api_key_configured = bool(api_key)
# Create security dependencies with proper descriptions for Swagger UI
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="login", auto_error=False, description="OAuth2 Password Authentication"
)
# If API key is configured, create an API key header security
api_key_header = None
if api_key_configured:
api_key_header = APIKeyHeader(
name="X-API-Key", auto_error=False, description="API Key Authentication"
)
async def combined_dependency(
request: Request,
response: Response, # Added: needed to return new token via response header
token: str = Security(oauth2_scheme),
api_key_header_value: Optional[str] = None
if api_key_header is None
else Security(api_key_header),
):
# 1. Check if path is in whitelist
path = request.url.path
for pattern, is_prefix in whitelist_patterns:
if (is_prefix and path.startswith(pattern)) or (
not is_prefix and path == pattern
):
return # Whitelist path, allow access
# 2. Validate token first if provided in the request (Ensure 401 error if token is invalid)
if token:
try:
token_info = auth_handler.validate_token(token)
# ========== Token Auto-Renewal Logic ==========
from lightrag.api.config import global_args
from datetime import datetime, timezone
if global_args.token_auto_renew:
# Check if current path should skip token renewal
skip_renewal = any(
path == skip_path or path.startswith(skip_path + "/")
for skip_path in _TOKEN_RENEWAL_SKIP_PATHS
)
if skip_renewal:
logger.debug(f"Token auto-renewal skipped for path: {path}")
else:
try:
expire_time = token_info.get("exp")
if expire_time:
# Calculate remaining time ratio
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
# Get original token expiration duration
role = token_info.get("role", "user")
total_hours = (
auth_handler.guest_expire_hours
if role == "guest"
else auth_handler.expire_hours
)
total_seconds = total_hours * 3600
# Issue new token if remaining time < threshold
if (
remaining_seconds
< total_seconds * global_args.token_renew_threshold
):
# ========== Rate Limiting Check ==========
username = token_info["username"]
current_time = time.time()
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = (
current_time - last_renewal
)
# Only renew if enough time has passed since last renewal
if time_since_last_renewal >= _RENEWAL_MIN_INTERVAL:
new_token = auth_handler.create_token(
username=username,
role=role,
metadata=token_info.get("metadata", {}),
)
# Return new token via response header
response.headers["X-New-Token"] = new_token
# Update renewal cache
_token_renewal_cache[username] = current_time
# Optional: log renewal
logger.info(
f"Token auto-renewed for user {username} "
f"(role: {role}, remaining: {remaining_seconds:.0f}s)"
)
else:
# Log skip due to rate limit
logger.debug(
f"Token renewal skipped for {username} "
f"(rate limit: last renewal {time_since_last_renewal:.0f}s ago)"
)
# ========== End of Rate Limiting Check ==========
except Exception as e:
# Renewal failure should not affect normal request, just log
logger.warning(f"Token auto-renew failed: {e}")
# ========== End of Token Auto-Renewal Logic ==========
# A token only authenticates when it matches the configured auth mode:
# - password auth (AUTH_ACCOUNTS set): accept non-guest user tokens
# - fully open (no AUTH_ACCOUNTS, no API key): accept guest tokens
# In the API-key-only profile (API key set, no AUTH_ACCOUNTS) a guest
# token must NOT authenticate: anyone can obtain one (via /auth-status,
# /login, or by signing it with the public default secret), so honoring
# it here would let a forged guest token bypass the X-API-Key check
# below (GHSA-f4vv-55c2-5789 / GHSA-xr5c-v5r6-c9f9). Instead, fall
# through so the API key stays mandatory in that mode.
if not auth_configured and token_info.get("role") == "guest":
if not api_key_configured:
return
# API-key-only mode: ignore the guest token; the X-API-Key check
# below is the sole authority. Fall through (no return, no raise).
elif auth_configured and token_info.get("role") != "guest":
# Accept non-guest token if password auth is configured
return
else:
# Token present but not valid for the configured auth mode.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token. Please login again.",
)
except HTTPException as e:
# If already a 401 error, re-raise it
if e.status_code == status.HTTP_401_UNAUTHORIZED:
raise
# For other exceptions, continue processing
# 3. Acept all request if no API protection needed
if not auth_configured and not api_key_configured:
return
# 4. Validate API key if provided and API-Key authentication is configured
if (
api_key_configured
and api_key_header_value
and api_key_header_value == api_key
):
return # API key validation successful
### Authentication failed ####
# if password authentication is configured but not provided, ensure 401 error if auth_configured
if auth_configured and not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No credentials provided. Please login.",
)
# if api key is provided but validation failed
if api_key_header_value:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="Invalid API Key",
)
# if api_key_configured but not provided
if api_key_configured and not api_key_header_value:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="API Key required",
)
# Otherwise: refuse access and return 403 error
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="API Key required or login authentication required.",
)
return combined_dependency
def get_auth_status_dependency(api_key: Optional[str] = None):
"""Create a dependency that reports whether the request carries accepted
credentials, WITHOUT enforcing authentication (it never raises).
Used by endpoints such as ``/health`` that must stay reachable for
unauthenticated liveness probes (always HTTP 200) while only revealing
sensitive configuration to authenticated callers. The acceptance rules
mirror ``get_combined_auth_dependency`` exactly:
- fully open (no AUTH_ACCOUNTS, no API key): nothing is protected
anywhere, so the request is treated as authenticated.
- password auth (AUTH_ACCOUNTS set): a valid non-guest token, or a
valid API key when one is configured, authenticates.
- API-key-only (API key set, no AUTH_ACCOUNTS): only a valid API key
authenticates; a guest token is forgeable and must NOT count
(GHSA-f4vv-55c2-5789 / GHSA-xr5c-v5r6-c9f9).
"""
api_key_configured = bool(api_key)
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="login", auto_error=False, description="OAuth2 Password Authentication"
)
api_key_header = None
if api_key_configured:
api_key_header = APIKeyHeader(
name="X-API-Key", auto_error=False, description="API Key Authentication"
)
async def auth_status_dependency(
token: str = Security(oauth2_scheme),
api_key_header_value: Optional[str] = None
if api_key_header is None
else Security(api_key_header),
) -> bool:
# Fully-open mode: nothing is protected anywhere, so reveal config too.
if not auth_configured and not api_key_configured:
return True
# A valid API key authenticates in any mode where one is configured.
if (
api_key_configured
and api_key_header_value
and api_key_header_value == api_key
):
return True
if token:
try:
token_info = auth_handler.validate_token(token)
except Exception:
token_info = None
if token_info:
role = token_info.get("role")
# Password auth: accept a non-guest token. A guest token never
# authenticates here (in API-key-only mode it is forgeable).
if auth_configured and role != "guest":
return True
return False
return auth_status_dependency
def whitelist_exposes_api_routes(whitelist_paths: str) -> bool:
"""Return True if WHITELIST_PATHS exempts any Ollama-compatible /api route.
Mirrors the prefix/exact matching in get_combined_auth_dependency so that a
catch-all entry such as ``/*`` (which strips to an empty prefix and matches
every request path, including ``/api/chat``) is recognized as exposing the
/api routes — not just literal ``/api...`` entries.
"""
for entry in whitelist_paths.split(","):
entry = entry.strip()
if not entry:
continue
if entry.endswith("/*"):
# Prefix match: this entry exempts an /api route when some /api path
# starts with the prefix ("/api".startswith(prefix) also covers the
# empty catch-all prefix from "/*") or the prefix is itself under
# /api/. The "/api/" boundary matters: "/apiary/*" only exempts
# /apiary..., not /api/chat, so it must NOT be flagged.
prefix = entry[:-2]
if "/api".startswith(prefix) or prefix.startswith("/api/"):
return True
else:
# Exact match: only the literal path is exempted.
if entry == "/api" or entry.startswith("/api/"):
return True
return False
def display_splash_screen(args: argparse.Namespace) -> None:
"""
Display a colorful splash screen showing LightRAG server configuration
Args:
args: Parsed command line arguments
"""
# Banner
# Banner
top_border = "╔══════════════════════════════════════════════════════════════╗"
bottom_border = "╚══════════════════════════════════════════════════════════════╝"
width = len(top_border) - 4 # width inside the borders
line1_text = f"LightRAG Server v{core_version}/{api_version}"
line2_text = "Fast, Lightweight RAG Server Implementation"
line1 = f"{line1_text.center(width)}"
line2 = f"{line2_text.center(width)}"
banner = f"""
{top_border}
{line1}
{line2}
{bottom_border}
"""
ASCIIColors.cyan(banner)
# Server Configuration
ASCIIColors.magenta("\n📡 Server Configuration:")
ASCIIColors.white(" ├─ Host: ", end="")
ASCIIColors.yellow(f"{args.host}")
ASCIIColors.white(" ├─ Port: ", end="")
ASCIIColors.yellow(f"{args.port}")
ASCIIColors.white(" ├─ Workers: ", end="")
ASCIIColors.yellow(f"{args.workers}")
ASCIIColors.white(" ├─ Timeout: ", end="")
ASCIIColors.yellow(f"{args.timeout}")
ASCIIColors.white(" ├─ CORS Origins: ", end="")
ASCIIColors.yellow(f"{args.cors_origins}")
ASCIIColors.white(" ├─ SSL Enabled: ", end="")
ASCIIColors.yellow(f"{args.ssl}")
if args.ssl:
ASCIIColors.white(" ├─ SSL Cert: ", end="")
ASCIIColors.yellow(f"{args.ssl_certfile}")
ASCIIColors.white(" ├─ SSL Key: ", end="")
ASCIIColors.yellow(f"{args.ssl_keyfile}")
ASCIIColors.white(" ├─ Ollama Emulating Model: ", end="")
ASCIIColors.yellow(f"{ollama_server_infos.LIGHTRAG_MODEL}")
ASCIIColors.white(" ├─ Log Level: ", end="")
ASCIIColors.yellow(f"{args.log_level}")
ASCIIColors.white(" ├─ Verbose Debug: ", end="")
ASCIIColors.yellow(f"{args.verbose}")
ASCIIColors.white(" ├─ API Key: ", end="")
ASCIIColors.yellow("Set" if args.key else "Not Set")
ASCIIColors.white(" └─ JWT Auth: ", end="")
ASCIIColors.yellow("Enabled" if args.auth_accounts else "Disabled")
# Directory Configuration
ASCIIColors.magenta("\n📂 Directory Configuration:")
ASCIIColors.white(" ├─ Working Directory: ", end="")
ASCIIColors.yellow(f"{args.working_dir}")
ASCIIColors.white(" └─ Input Directory: ", end="")
ASCIIColors.yellow(f"{args.input_dir}")
# Embedding Configuration
ASCIIColors.magenta("\n📊 Embedding Configuration:")
ASCIIColors.white(" ├─ Binding: ", end="")
ASCIIColors.yellow(f"{args.embedding_binding}")
ASCIIColors.white(" ├─ Host: ", end="")
ASCIIColors.yellow(f"{args.embedding_binding_host}")
ASCIIColors.white(" ├─ Model: ", end="")
ASCIIColors.yellow(f"{args.embedding_model}")
ASCIIColors.white(" ├─ Dimensions: ", end="")
ASCIIColors.yellow(f"{args.embedding_dim}")
ASCIIColors.white(" └─ Asymmetric: ", end="")
ASCIIColors.yellow(f"{args.embedding_asymmetric}")
# RAG Configuration
ASCIIColors.magenta("\n⚙️ RAG Configuration:")
ASCIIColors.white(" ├─ Summary Language: ", end="")
ASCIIColors.yellow(f"{args.summary_language}")
ASCIIColors.white(" ├─ Max Parallel Insert: ", end="")
ASCIIColors.yellow(f"{args.max_parallel_insert}")
ASCIIColors.white(" ├─ Chunk Size: ", end="")
ASCIIColors.yellow(f"{args.chunk_size}")
ASCIIColors.white(" ├─ Chunk Overlap Size: ", end="")
ASCIIColors.yellow(f"{args.chunk_overlap_size}")
ASCIIColors.white(" ├─ Cosine Threshold: ", end="")
ASCIIColors.yellow(f"{args.cosine_threshold}")
ASCIIColors.white(" ├─ Top-K: ", end="")
ASCIIColors.yellow(f"{args.top_k}")
ASCIIColors.white(" └─ Force LLM Summary on Merge: ", end="")
ASCIIColors.yellow(
f"{get_env_value('FORCE_LLM_SUMMARY_ON_MERGE', DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int)}"
)
# System Configuration
ASCIIColors.magenta("\n💾 Storage Configuration:")
ASCIIColors.white(" ├─ KV Storage: ", end="")
ASCIIColors.yellow(f"{args.kv_storage}")
ASCIIColors.white(" ├─ Vector Storage: ", end="")
ASCIIColors.yellow(f"{args.vector_storage}")
ASCIIColors.white(" ├─ Graph Storage: ", end="")
ASCIIColors.yellow(f"{args.graph_storage}")
ASCIIColors.white(" ├─ Document Status Storage: ", end="")
ASCIIColors.yellow(f"{args.doc_status_storage}")
ASCIIColors.white(" └─ Workspace: ", end="")
ASCIIColors.yellow(f"{args.workspace if args.workspace else '-'}")
# Server Status
ASCIIColors.green("\n✨ Server starting up...\n")
# Server Access Information
protocol = "https" if args.ssl else "http"
if args.host == "0.0.0.0":
ASCIIColors.magenta("\n🌐 Server Access Information:")
ASCIIColors.white(" ├─ WebUI (local): ", end="")
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}")
ASCIIColors.white(" ├─ Remote Access: ", end="")
ASCIIColors.yellow(f"{protocol}://<your-ip-address>:{args.port}")
ASCIIColors.white(" ├─ API Documentation (local): ", end="")
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/docs")
ASCIIColors.white(" └─ Alternative Documentation (local): ", end="")
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/redoc")
ASCIIColors.magenta("\n📝 Note:")
ASCIIColors.cyan(""" Since the server is running on 0.0.0.0:
- Use 'localhost' or '127.0.0.1' for local access
- Use your machine's IP address for remote access
- To find your IP address:
• Windows: Run 'ipconfig' in terminal
• Linux/Mac: Run 'ifconfig' or 'ip addr' in terminal
""")
else:
base_url = f"{protocol}://{args.host}:{args.port}"
ASCIIColors.magenta("\n🌐 Server Access Information:")
ASCIIColors.white(" ├─ WebUI (local): ", end="")
ASCIIColors.yellow(f"{base_url}")
ASCIIColors.white(" ├─ API Documentation: ", end="")
ASCIIColors.yellow(f"{base_url}/docs")
ASCIIColors.white(" └─ Alternative Documentation: ", end="")
ASCIIColors.yellow(f"{base_url}/redoc")
# Security Notice
if args.key:
ASCIIColors.white("✅ Security Notice:")
ASCIIColors.white(""" API Key authentication is enabled.
Make sure to include the X-API-Key header in all your requests.
""")
if args.auth_accounts:
ASCIIColors.white("✅ Security Notice:")
ASCIIColors.white(""" JWT authentication is enabled.
Make sure to login before making the request, and include the 'Authorization' in the header.
""")
# Warn when the server runs without any authentication. In this mode every
# endpoint is publicly reachable (see get_combined_auth_dependency: with
# neither AUTH_ACCOUNTS nor LIGHTRAG_API_KEY set, all requests are allowed).
if not args.key and not args.auth_accounts:
loopback_hosts = {"127.0.0.1", "::1", "localhost"}
if args.host in loopback_hosts:
ASCIIColors.yellow("\n⚠️ Security Warning:")
ASCIIColors.white(f""" No authentication is configured (no API Key, no login accounts).
The server is bound to a loopback address ('{args.host}'), so it is only
reachable from this machine. Set LIGHTRAG_API_KEY, or AUTH_ACCOUNTS together
with TOKEN_SECRET, before binding to a non-loopback address (e.g. HOST=0.0.0.0).
""")
else:
ASCIIColors.red("\n🔴 SECURITY ALERT:")
ASCIIColors.white(f""" The server is listening on '{args.host}' WITHOUT any authentication.
Every endpoint (document upload, query, knowledge graph, deletion) is
publicly accessible to anyone who can reach this address.
Secure the server before exposing it to a network by setting at least one of:
- LIGHTRAG_API_KEY=<a-strong-secret> (X-API-Key header authentication)
- AUTH_ACCOUNTS=user:password together with TOKEN_SECRET=<a-strong-secret>
(JWT login authentication; AUTH_ACCOUNTS
without TOKEN_SECRET fails to start)
Or restrict access by binding to loopback only: HOST=127.0.0.1
""")
# When authentication IS configured but the server is exposed on a
# non-loopback address, warn that the default whitelist still exempts the
# Ollama-compatible /api/* routes (kept open for Ollama-client compatibility).
# Those routes invoke the LLM and read the knowledge base, so they stay
# public unless the operator narrows WHITELIST_PATHS (e.g. to /health).
if args.key or args.auth_accounts:
loopback_hosts = {"127.0.0.1", "::1", "localhost"}
ollama_open = whitelist_exposes_api_routes(args.whitelist_paths)
if args.host not in loopback_hosts and ollama_open:
ASCIIColors.yellow("\n⚠️ Security Warning:")
ASCIIColors.white(f""" WHITELIST_PATHS ('{args.whitelist_paths}') exempts the Ollama-compatible
/api/* routes (/api/chat, /api/generate, ...) from authentication, so they
remain publicly accessible on '{args.host}' even though auth is enabled.
These routes invoke the LLM and read your knowledge base. If you do not need
open Ollama access, set WHITELIST_PATHS=/health to require authentication.
""")
# Ensure splash output flush to system log
sys.stdout.flush()
+1068
View File
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
"""Chunk schema helpers shared across the chunking + extraction pipeline.
Three responsibilities live here so chunker implementations and the pipeline
both consume identical normalization rules:
- :func:`normalize_chunk_heading` collapses the legacy flat
``heading``/``parent_headings``/``level`` triple and the new nested form
into the canonical ``{"level", "heading", "parent_headings"}`` dict.
- :func:`normalize_chunk_sidecar` validates the new ``sidecar`` payload and
ensures ``refs`` is always present as a list (single-source items may omit
it before normalization; we materialize a single-element list for the
storage layer).
- :func:`strip_internal_multimodal_markup_for_extraction` rewrites
``<cite>`` / ``<drawing>`` / ``<equation>`` markup so the entity-extraction
LLM sees a clean text body. The original ``chunk["content"]`` is never
mutated; the cleaned string is only used to build the extraction prompt.
The clean function is intentionally conservative: it only strips
parser-emitted identifier attributes that have no business reaching the LLM
(``id``, ``refid``, ``path``, ``src``). Visible captions and equation bodies
are preserved so the extracted entities can still ground against them.
"""
from __future__ import annotations
import re
import unicodedata
from typing import Any
from lightrag.constants import DEFAULT_HEADING_LEVEL_MAX_CHARS
_SIDECAR_TYPES = frozenset({"block", "drawing", "table", "equation"})
# Separator joining heading levels into a single breadcrumb line. Shared so the
# extraction-side token budgeter (see ``operate._truncate_section_context``) can
# split the rendered breadcrumb back into levels without a drifting magic string.
HEADING_BREADCRUMB_SEP = ""
def normalize_chunk_heading(dp: dict[str, Any]) -> dict[str, Any] | None:
"""Return the canonical nested heading dict or ``None`` when absent.
Accepts:
- ``dp["heading"]`` already a dict ``{"level", "heading", "parent_headings"}``.
- Legacy flat fields ``heading: str`` + ``parent_headings: list[str]`` +
``level: int``.
Empty / missing inputs collapse to ``None`` so callers can simply omit
the field when writing the chunk record.
"""
nested = dp.get("heading")
if isinstance(nested, dict):
heading_text = str(nested.get("heading") or "").strip()
parents_raw = nested.get("parent_headings") or []
level_raw = nested.get("level", 0)
else:
heading_text = str(nested or "").strip()
parents_raw = dp.get("parent_headings") or []
level_raw = dp.get("level", 0)
parent_headings: list[str] = []
if isinstance(parents_raw, list):
for entry in parents_raw:
text = str(entry or "").strip()
if text:
parent_headings.append(text)
try:
level = int(level_raw or 0)
except (TypeError, ValueError):
level = 0
if not heading_text and not parent_headings and level == 0:
return None
return {
"level": level,
"heading": heading_text,
"parent_headings": parent_headings,
}
_HEADING_WHITESPACE_RE = re.compile(r"\s+")
# Unicode categories stripped from a heading: control (Cc) and format (Cf)
# chars — NULs, zero-width marks (ZWSP/ZWNJ/ZWJ/WORD JOINER/BOM are all Cf),
# directional/format codes — that only add token noise to the LLM prompt.
_HEADING_STRIP_CATEGORIES = frozenset({"Cc", "Cf"})
# The only Cc chars that ``\s`` folds into a space; keep them through the strip
# pass so they become a space below instead of gluing adjacent words. (Cannot
# use ``str.isspace()`` for this: Python treats \x1c-\x1f as whitespace but
# ``\s`` does not, so those would survive the strip — an explicit set matches
# the regex.)
_HEADING_KEEP_WS = frozenset("\t\n\r\f\v")
def _clean_heading_text(text: str) -> str:
"""Flatten a heading into one clean line for the LLM.
Converts the breadcrumb separator ``→`` to a space, drops every Unicode
control (Cc) / format (Cf) char (zero-width marks, NULs, directional/format
codes), and collapses every run of whitespace (tab, newline, NBSP,
full-width / ideographic space, ...) into a single regular space as the
final step. Normal CJK, Latin, digits, and punctuation are left untouched,
and no spaces are inserted between adjacent CJK characters, so it is safe
for Chinese headings.
"""
# ``→`` (U+2192, the breadcrumb separator char) must never survive inside a
# single heading, or it would forge an extra level when the breadcrumb is
# split back on " → " (see operate._truncate_section_context).
text = text.replace("", " ")
text = "".join(
ch
for ch in text
if ch in _HEADING_KEEP_WS
or unicodedata.category(ch) not in _HEADING_STRIP_CATEGORIES
)
# Collapse LAST so the spaces introduced above (→, kept whitespace controls,
# and any gap left around a removed control char) all fold into one space.
text = _HEADING_WHITESPACE_RE.sub(" ", text)
return text.strip()
def _truncate_heading_level(text: str, max_chars: int) -> str:
"""Hard-cap a single heading level, marking elision with an ellipsis."""
if max_chars <= 0 or len(text) <= max_chars:
return text
# Reserve one char for the ellipsis so the result length stays <= max_chars.
return text[: max_chars - 1].rstrip() + ""
def _clean_and_cap_headings(headings: list[str], max_heading_len: int) -> list[str]:
"""Clean each heading, drop empties, then hard-cap each level's length.
Shared by :func:`format_parent_headings` and :func:`format_heading_context`
so both the query-stage and extraction-stage breadcrumbs apply identical
cleaning (:func:`_clean_heading_text`) and per-level truncation, and cannot
drift apart. Order matters: clean → drop empties → cap.
"""
return [
_truncate_heading_level(c, max_heading_len)
for c in (_clean_heading_text(h) for h in headings)
if c
]
def format_parent_headings(
dp: dict[str, Any],
*,
max_heading_len: int = DEFAULT_HEADING_LEVEL_MAX_CHARS,
) -> str:
"""Join a chunk's parent heading chain into ``h1 → h2 → h3``.
Reuses :func:`normalize_chunk_heading` so both the nested and legacy flat
heading shapes are handled, then cleans each heading via
:func:`_clean_heading_text` so the string sent to the LLM is a single tidy
line. Returns an empty string when the chunk has no (non-empty) parent
headings, so callers can simply omit the field when it is empty.
Each individual heading level is capped at ``max_heading_len`` characters
(set ``<= 0`` to disable), matching :func:`format_heading_context`, so one
runaway title cannot bloat the query context before its token truncation.
"""
normalized = normalize_chunk_heading(dp)
if not normalized:
return ""
cleaned = _clean_and_cap_headings(normalized["parent_headings"], max_heading_len)
return HEADING_BREADCRUMB_SEP.join(cleaned)
def format_heading_context(
dp: dict[str, Any],
*,
max_heading_len: int = DEFAULT_HEADING_LEVEL_MAX_CHARS,
) -> str:
"""Join a chunk's full heading chain (parents + current) into ``h1 → h2 → h3``.
Like :func:`format_parent_headings` but appends the chunk's own section
heading after the parent chain, so the entity-extraction LLM sees the
complete breadcrumb of the section the input text belongs to. Reuses
:func:`normalize_chunk_heading` (handles both nested and legacy flat shapes)
and :func:`_clean_heading_text`. Returns an empty string when the chunk
carries no (non-empty) heading information, so callers can simply omit the
field when it is empty.
Each individual heading level is capped at ``max_heading_len`` characters
(set ``<= 0`` to disable) so one runaway title cannot bloat the prompt; the
caller is still responsible for token-budgeting the joined breadcrumb.
"""
normalized = normalize_chunk_heading(dp)
if not normalized:
return ""
chain = list(normalized["parent_headings"])
if normalized["heading"]:
chain.append(normalized["heading"])
cleaned = _clean_and_cap_headings(chain, max_heading_len)
return HEADING_BREADCRUMB_SEP.join(cleaned)
def normalize_chunk_sidecar(dp: dict[str, Any]) -> dict[str, Any] | None:
"""Return the canonical sidecar dict or ``None`` when absent / invalid.
Output shape::
{"type": <one of block|drawing|table|equation>,
"id": <primary source id>,
"refs": [{"type": ..., "id": ...}, ...]}
``refs`` is always materialized as a list with at least the primary id.
Single-source chunks therefore land in storage with ``refs=[{type,id}]``
so downstream consumers don't need to special-case the field's presence.
"""
sidecar = dp.get("sidecar")
if not isinstance(sidecar, dict):
return None
sidecar_type = str(sidecar.get("type") or "").strip()
sidecar_id = str(sidecar.get("id") or "").strip()
if sidecar_type not in _SIDECAR_TYPES or not sidecar_id:
return None
refs_raw = sidecar.get("refs")
refs: list[dict[str, str]] = []
if isinstance(refs_raw, list):
for entry in refs_raw:
if not isinstance(entry, dict):
continue
ref_type = str(entry.get("type") or "").strip()
ref_id = str(entry.get("id") or "").strip()
if ref_type in _SIDECAR_TYPES and ref_id:
refs.append({"type": ref_type, "id": ref_id})
if not refs:
refs = [{"type": sidecar_type, "id": sidecar_id}]
return {"type": sidecar_type, "id": sidecar_id, "refs": refs}
# `<cite type="..." refid="...">visible text</cite>` → `visible text`.
_CITE_RE = re.compile(
r"<cite\b[^>]*>(.*?)</cite>",
flags=re.IGNORECASE | re.DOTALL,
)
# Inner attribute stripper used when the caller wants to *preserve* the
# `<cite type="…">…</cite>` wrapper but drop the parser-internal `refid`.
# Matches ` refid="…"` (leading whitespace + quoted value) so the
# surrounding attribute layout (e.g. `type="table"`) stays intact.
_CITE_REFID_ATTR_RE = re.compile(
r'\s+refid\s*=\s*"[^"]*"',
flags=re.IGNORECASE,
)
# Self-closing `<drawing ...>` placeholder. We keep `caption` (visible) and
# drop `id`, `path`, `src`, `format`, etc. Tags without any caption are
# removed entirely so they don't pollute extraction input.
_DRAWING_RE = re.compile(
r"<drawing\b([^>]*)/>",
flags=re.IGNORECASE,
)
# Container `<equation id="..." format="...">latex</equation>`. Strip
# identifier attributes; preserve the body and the `format` attribute so
# extraction still sees the equation is a structured element.
_EQUATION_RE = re.compile(
r"<equation\b([^>]*)>(.*?)</equation>",
flags=re.IGNORECASE | re.DOTALL,
)
# Container `<table id="tb-..." format="json" caption="...">rows</table>`.
# Native parser emits the internal ``tb-<doc>-NNNN`` identifier here, which
# would otherwise leak into the entity-extraction prompt and become a noisy
# entity. Strip ``id``; keep ``format`` / ``caption`` (and the body verbatim)
# so the extractor still recognizes the element as a structured table.
_TABLE_RE = re.compile(
r"<table\b([^>]*)>(.*?)</table>",
flags=re.IGNORECASE | re.DOTALL,
)
# Match attribute pairs like ``caption="text with \"escapes\""``. We treat
# only the safe identifier-style attributes; complex quoting is rare in
# parser output.
_ATTR_RE = re.compile(
r'(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"',
)
def _attrs_to_dict(attr_string: str) -> dict[str, str]:
return {
match.group(1).lower(): match.group(2)
for match in _ATTR_RE.finditer(attr_string)
}
def _format_attrs(pairs: list[tuple[str, str]]) -> str:
return "".join(f' {k}="{v}"' for k, v in pairs if v)
def _replace_drawing(match: re.Match[str]) -> str:
attrs = _attrs_to_dict(match.group(1))
caption = attrs.get("caption", "")
if not caption.strip():
return ""
return f"<drawing{_format_attrs([('caption', caption)])} />"
def _replace_equation(match: re.Match[str]) -> str:
attrs = _attrs_to_dict(match.group(1))
body = match.group(2)
keep: list[tuple[str, str]] = []
fmt = attrs.get("format", "")
if fmt:
keep.append(("format", fmt))
caption = attrs.get("caption", "")
if caption.strip():
keep.append(("caption", caption))
return f"<equation{_format_attrs(keep)}>{body}</equation>"
def _replace_table(match: re.Match[str]) -> str:
attrs = _attrs_to_dict(match.group(1))
body = match.group(2)
keep: list[tuple[str, str]] = []
fmt = attrs.get("format", "")
if fmt:
keep.append(("format", fmt))
caption = attrs.get("caption", "")
if caption.strip():
keep.append(("caption", caption))
return f"<table{_format_attrs(keep)}>{body}</table>"
def strip_internal_multimodal_markup_for_extraction(
content: str, *, keep_cite_tag: bool = False
) -> str:
"""Strip parser-internal identifiers from a chunk content string.
Only the entity-extraction prompt should receive the cleaned form;
callers must NOT mutate the stored chunk ``content`` so query-time
citations still resolve back to the original parser output.
Transformations always applied:
- ``<drawing id="im-…" path="" src="" caption="Fig 1" />``
→ ``<drawing caption="Fig 1" />``
(drops the entire tag when no caption is present)
- ``<table id="tb-…" format="json" caption="">rows</table>``
→ ``<table format="json" caption="">rows</table>``
- ``<equation id="eq-…" format="latex">…</equation>``
→ ``<equation format="latex">…</equation>``
Cite-tag handling depends on ``keep_cite_tag``:
- ``keep_cite_tag=False`` (default — entity-extraction path):
``<cite type="" refid="">Table 1</cite>`` → ``Table 1``. The
cite wrapper is dropped so the extractor does not surface it as
a noisy structural entity.
- ``keep_cite_tag=True`` (multimodal-analysis surrounding path):
``<cite type="table" refid="">Table 1</cite>`` →
``<cite type="table">Table 1</cite>``. Only the internal
``refid`` is removed; the wrapper survives so the VLM/LLM can
tell visible reference labels (e.g. "Table 1") apart from inline
prose.
"""
if not content:
return content
if keep_cite_tag:
cleaned = _CITE_REFID_ATTR_RE.sub("", content)
else:
cleaned = _CITE_RE.sub(lambda m: m.group(1), content)
cleaned = _DRAWING_RE.sub(_replace_drawing, cleaned)
cleaned = _TABLE_RE.sub(_replace_table, cleaned)
cleaned = _EQUATION_RE.sub(_replace_equation, cleaned)
return cleaned
__all__ = [
"normalize_chunk_heading",
"format_parent_headings",
"format_heading_context",
"HEADING_BREADCRUMB_SEP",
"normalize_chunk_sidecar",
"strip_internal_multimodal_markup_for_extraction",
]
+66
View File
@@ -0,0 +1,66 @@
"""LightRAG chunking strategies.
Two contracts coexist intentionally:
- **Legacy contract** — :func:`chunking_by_token_size` keeps its
historical 6-positional-arg signature
``(tokenizer, content, split_by_character,
split_by_character_only, chunk_overlap_token_size,
chunk_token_size)``
so externally-supplied :attr:`lightrag.LightRAG.chunking_func`
implementations continue to work unchanged. The legacy contract is
only invoked when ``process_options`` does NOT specify a chunking
selector (i.e. ``chunking_explicit`` is False) — typically direct
:meth:`LightRAG.ainsert` calls with raw text.
- **File-chunker contract** — for documents whose ``process_options``
explicitly selects a chunking strategy, the file-based dispatcher in
``_PipelineMixin.process_single_document`` reads
``doc_process_opts.chunking`` and routes to a chunker following the
standardized signature
``(tokenizer, content, chunk_token_size, *,
<strategy-specific kwargs>)``
Currently shipped file chunkers:
- :func:`chunking_by_fixed_token` — the ``"F"`` strategy. Same
algorithm as :func:`chunking_by_token_size`, surfaced under the
new contract.
- :func:`chunking_by_recursive_character` — the ``"R"`` strategy.
Wraps LangChain ``RecursiveCharacterTextSplitter``; recursively
splits on a separator cascade with token-aware sizing.
- :func:`chunking_by_semantic_vector` — the ``"V"`` strategy.
Wraps LangChain ``SemanticChunker``; sentence-level embedding
similarity finds breakpoints. Async; needs an
:class:`~lightrag.utils.EmbeddingFunc`.
- :func:`chunking_by_paragraph_semantic` — the ``"P"`` strategy.
Heading-aware semantic chunker; consumes the docx-native
``.blocks.jsonl`` sidecar. Falls back to R when the sidecar is
missing or unreadable.
See ``docs/ParagraphSemanticChunking-zh.md`` for the algorithm behind
the ``"P"`` strategy and ``docs/FileProcessingConfiguration-zh.md`` for
how ``process_options`` and the new ``chunk_options`` snapshot drive
chunker selection per document.
"""
from lightrag.chunker.paragraph_semantic import chunking_by_paragraph_semantic
from lightrag.chunker.recursive_character import (
chunking_by_recursive_character,
)
from lightrag.chunker.semantic_vector import chunking_by_semantic_vector
from lightrag.chunker.token_size import (
chunking_by_fixed_token,
chunking_by_token_size,
)
__all__ = [
"chunking_by_fixed_token",
"chunking_by_paragraph_semantic",
"chunking_by_recursive_character",
"chunking_by_semantic_vector",
"chunking_by_token_size",
]
File diff suppressed because it is too large Load Diff
+402
View File
@@ -0,0 +1,402 @@
"""Recursive character chunking — the ``"R"`` strategy.
Wraps LangChain's :class:`RecursiveCharacterTextSplitter` and delivers
output rows in the LightRAG file-chunker schema. The splitter walks the
``separators`` list from longest semantic boundary (``\\n\\n`` by default)
to weakest (the empty string), recursively re-splitting any segment that
still exceeds the token cap.
Token accounting goes through the LightRAG :class:`Tokenizer` via the
``length_function`` plug-in — without that, ``chunk_size`` would be
measured in characters and ``chunk_token_size`` would lose its meaning.
Output cap is *not* enforced internally: oversized segments are produced
when no separator can break them, and
:func:`lightrag.utils.enforce_chunk_token_limit_before_embedding` does the
final hard split before embedding.
"""
from __future__ import annotations
import re
from collections.abc import Callable, Sequence
from typing import Any
from lightrag.utils import Tokenizer, logger
try:
from langchain_text_splitters import RecursiveCharacterTextSplitter
_LANGCHAIN_TEXT_SPLITTERS_AVAILABLE = True
except ImportError:
_LANGCHAIN_TEXT_SPLITTERS_AVAILABLE = False
RecursiveCharacterTextSplitter = None # type: ignore[assignment]
_SpanPiece = tuple[str, int, int]
def _split_text_with_regex_spans(
text: str,
separator_pattern: str,
*,
keep_separator: bool | str,
base_offset: int,
) -> list[_SpanPiece]:
"""Mirror LangChain's regex split while retaining source offsets."""
if not separator_pattern:
return [
(char, base_offset + index, base_offset + index + 1)
for index, char in enumerate(text)
if char
]
matches = list(re.finditer(separator_pattern, text))
if not matches:
return [(text, base_offset, base_offset + len(text))] if text else []
pieces: list[_SpanPiece] = []
if keep_separator:
if keep_separator == "end":
cursor = 0
for match in matches:
if match.end() > cursor:
pieces.append(
(
text[cursor : match.end()],
base_offset + cursor,
base_offset + match.end(),
)
)
cursor = match.end()
if cursor < len(text):
pieces.append(
(text[cursor:], base_offset + cursor, base_offset + len(text))
)
else:
first = matches[0]
if first.start() > 0:
pieces.append(
(text[: first.start()], base_offset, base_offset + first.start())
)
for index, match in enumerate(matches):
end = (
matches[index + 1].start()
if index + 1 < len(matches)
else len(text)
)
if end > match.start():
pieces.append(
(
text[match.start() : end],
base_offset + match.start(),
base_offset + end,
)
)
else:
cursor = 0
for match in matches:
if match.start() > cursor:
pieces.append(
(
text[cursor : match.start()],
base_offset + cursor,
base_offset + match.start(),
)
)
cursor = match.end()
if cursor < len(text):
pieces.append(
(text[cursor:], base_offset + cursor, base_offset + len(text))
)
return [piece for piece in pieces if piece[0]]
def _join_span_pieces(
pieces: list[_SpanPiece],
separator: str,
*,
strip_whitespace: bool,
) -> _SpanPiece | None:
"""Join split pieces exactly as LangChain does and compute the trimmed span."""
if not pieces:
return None
chars: list[str] = []
char_offsets: list[int] = []
for index, (fragment, start, end) in enumerate(pieces):
if index > 0 and separator:
previous_end = pieces[index - 1][2]
for sep_index, sep_char in enumerate(separator):
chars.append(sep_char)
char_offsets.append(previous_end + sep_index)
chars.extend(fragment)
char_offsets.extend(range(start, end))
text = "".join(chars)
if strip_whitespace:
left = 0
right = len(text)
while left < right and text[left].isspace():
left += 1
while right > left and text[right - 1].isspace():
right -= 1
else:
left, right = 0, len(text)
if left >= right:
return None
return text[left:right], char_offsets[left], char_offsets[right - 1] + 1
def _merge_splits_with_spans(
splits: Sequence[_SpanPiece],
separator: str,
*,
chunk_size: int,
chunk_overlap: int,
length_function: Callable[[str], int],
strip_whitespace: bool,
) -> list[_SpanPiece]:
"""Mirror ``TextSplitter._merge_splits`` while preserving source spans."""
separator_len = length_function(separator)
docs: list[_SpanPiece] = []
current_doc: list[_SpanPiece] = []
total = 0
for split in splits:
split_len = length_function(split[0])
if (
total + split_len + (separator_len if len(current_doc) > 0 else 0)
> chunk_size
):
if total > chunk_size:
logger.warning(
"Created a chunk of size %d, which is longer than the specified %d",
total,
chunk_size,
)
if len(current_doc) > 0:
doc = _join_span_pieces(
current_doc,
separator,
strip_whitespace=strip_whitespace,
)
if doc is not None:
docs.append(doc)
while total > chunk_overlap or (
total + split_len + (separator_len if len(current_doc) > 0 else 0)
> chunk_size
and total > 0
):
total -= length_function(current_doc[0][0]) + (
separator_len if len(current_doc) > 1 else 0
)
current_doc = current_doc[1:]
current_doc.append(split)
total += split_len + (separator_len if len(current_doc) > 1 else 0)
doc = _join_span_pieces(
current_doc,
separator,
strip_whitespace=strip_whitespace,
)
if doc is not None:
docs.append(doc)
return docs
def _split_text_with_spans(
text: str,
*,
base_offset: int,
separators: Sequence[str],
chunk_size: int,
chunk_overlap: int,
length_function: Callable[[str], int],
keep_separator: bool | str,
is_separator_regex: bool,
strip_whitespace: bool,
) -> list[_SpanPiece]:
"""Mirror ``RecursiveCharacterTextSplitter._split_text`` with offsets."""
separator = separators[-1]
new_separators: Sequence[str] = []
for index, candidate in enumerate(separators):
separator_pattern = candidate if is_separator_regex else re.escape(candidate)
if not candidate:
separator = candidate
break
if re.search(separator_pattern, text):
separator = candidate
new_separators = separators[index + 1 :]
break
separator_pattern = separator if is_separator_regex else re.escape(separator)
splits = _split_text_with_regex_spans(
text,
separator_pattern,
keep_separator=keep_separator,
base_offset=base_offset,
)
final_chunks: list[_SpanPiece] = []
good_splits: list[_SpanPiece] = []
merge_separator = "" if keep_separator else separator
for split in splits:
if length_function(split[0]) < chunk_size:
good_splits.append(split)
else:
if good_splits:
final_chunks.extend(
_merge_splits_with_spans(
good_splits,
merge_separator,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=length_function,
strip_whitespace=strip_whitespace,
)
)
good_splits = []
if not new_separators:
final_chunks.append(split)
else:
final_chunks.extend(
_split_text_with_spans(
split[0],
base_offset=split[1],
separators=new_separators,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=length_function,
keep_separator=keep_separator,
is_separator_regex=is_separator_regex,
strip_whitespace=strip_whitespace,
)
)
if good_splits:
final_chunks.extend(
_merge_splits_with_spans(
good_splits,
merge_separator,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=length_function,
strip_whitespace=strip_whitespace,
)
)
return final_chunks
def chunking_by_recursive_character(
tokenizer: Tokenizer,
content: str,
chunk_token_size: int = 1200,
*,
chunk_overlap_token_size: int = 100,
separators: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Recursive character splitter — the ``"R"`` chunking strategy.
Args:
tokenizer: LightRAG tokenizer; used as the length function so
``chunk_token_size`` and ``chunk_overlap_token_size`` are
interpreted in tokens, not characters.
content: Text to split.
chunk_token_size: Hard target size for each chunk (tokens).
chunk_overlap_token_size: Token overlap between adjacent chunks.
separators: Cascade of split candidates. ``None`` defers to
LangChain's defaults: ``["\\n\\n", "\\n", " ", ""]``.
Returns:
Ordered list of ``{"tokens", "content", "chunk_order_index"}``
dicts.
"""
if not _LANGCHAIN_TEXT_SPLITTERS_AVAILABLE:
raise ImportError(
"langchain-text-splitters is required for the 'R' chunking "
"strategy; install with `pip install langchain-text-splitters>=0.3`."
)
if not content or not content.strip():
return []
def length_function(text: str) -> int:
return len(tokenizer.encode(text))
splitter_kwargs: dict[str, Any] = {
"chunk_size": max(int(chunk_token_size), 1),
"chunk_overlap": max(int(chunk_overlap_token_size), 0),
"length_function": length_function,
"strip_whitespace": True,
}
if separators is not None:
splitter_kwargs["separators"] = list(separators)
splitter = RecursiveCharacterTextSplitter(**splitter_kwargs)
# We deliberately do *not* request LangChain's ``add_start_index``. That
# offset is computed with a character-vs-token unit mismatch when a
# token-based ``length_function`` is in play, and text-search recovery is
# ambiguous for repeated blocks. Instead we mirror LangChain's split/merge
# control flow while carrying each split unit's source offsets through it.
pieces = _split_text_with_spans(
content,
base_offset=0,
separators=list(splitter._separators),
chunk_size=int(splitter._chunk_size),
chunk_overlap=int(splitter._chunk_overlap),
length_function=length_function,
keep_separator=splitter._keep_separator,
is_separator_regex=bool(splitter._is_separator_regex),
strip_whitespace=bool(splitter._strip_whitespace),
)
results: list[dict[str, Any]] = []
for raw_body, start_index, end_index in pieces:
left = 0
right = len(raw_body)
while left < right and raw_body[left].isspace():
left += 1
while right > left and raw_body[right - 1].isspace():
right -= 1
body = raw_body[left:right]
if not body:
continue
start_index += left
end_index -= len(raw_body) - right
results.append(
{
"tokens": len(tokenizer.encode(body)),
"content": body,
"chunk_order_index": len(results),
"_source_span": {"start": start_index, "end": end_index},
}
)
if not results:
# Defensive: splitter returned only whitespace fragments. Fall
# through with a single chunk of stripped content so downstream
# callers always receive at least one row when input is non-empty.
logger.warning(
"[recursive_character] splitter produced no non-empty chunks "
"for %d-char input; emitting single fallback chunk.",
len(content),
)
body = content.strip()
if body:
start = content.find(body)
results.append(
{
"tokens": len(tokenizer.encode(body)),
"content": body,
"chunk_order_index": 0,
**(
{"_source_span": {"start": start, "end": start + len(body)}}
if start >= 0
else {}
),
}
)
return results
+349
View File
@@ -0,0 +1,349 @@
"""Semantic vector chunking — the ``"V"`` strategy.
Wraps LangChain's :class:`SemanticChunker` (from ``langchain-experimental``)
which splits text by sentence embeddings: it first segments the input into
sentences, embeds each sentence (in adjacent windows of ``buffer_size``),
and finds breakpoints where the cosine distance between consecutive
windows crosses a threshold derived from the chosen distribution
(``percentile`` / ``standard_deviation`` / ``interquartile`` /
``gradient``).
The chunker exposed here is ``async`` because LightRAG's
:class:`EmbeddingFunc` is async. Internally we call SemanticChunker
synchronously inside :func:`asyncio.to_thread` and bridge the embedding
calls back to the main event loop via
:func:`asyncio.run_coroutine_threadsafe`.
Caveats:
- SemanticChunker does NOT enforce a maximum chunk size; the caller's
``chunk_token_size`` is *advisory* here. Oversized chunks will be
hard-split before embedding by
:func:`lightrag.utils.enforce_chunk_token_limit_before_embedding`.
- When ``embedding_func`` is ``None`` we log a warning and fall back to
:func:`lightrag.chunker.chunking_by_recursive_character` — V's only
differentiator is embeddings, and R is the closest structural-only
alternative.
"""
from __future__ import annotations
import asyncio
import re
from typing import TYPE_CHECKING, Any
from lightrag.constants import DEFAULT_SENTENCE_SPLIT_REGEX
from lightrag.utils import EmbeddingFunc, Tokenizer, logger
if TYPE_CHECKING:
from langchain_experimental.text_splitter import (
SemanticChunker as SemanticChunkerType,
)
else:
SemanticChunkerType = Any
try:
from langchain_core.embeddings import Embeddings
from langchain_experimental.text_splitter import SemanticChunker
_LANGCHAIN_EXPERIMENTAL_AVAILABLE = True
except ImportError:
_LANGCHAIN_EXPERIMENTAL_AVAILABLE = False
Embeddings = object # type: ignore[assignment,misc]
SemanticChunker = None # type: ignore[assignment]
class _AsyncEmbeddingFuncAdapter(Embeddings):
"""Bridge a LightRAG :class:`EmbeddingFunc` (async) to LangChain's
sync :class:`Embeddings` interface used by ``SemanticChunker``.
The adapter must be constructed inside the running event loop so it
can capture the loop reference; the blocking ``embed_documents`` /
``embed_query`` calls are then made from a worker thread (via
:func:`asyncio.to_thread` in the public chunker) and bounce back to
the captured loop with :func:`asyncio.run_coroutine_threadsafe`.
"""
def __init__(
self,
embedding_func: EmbeddingFunc,
loop: asyncio.AbstractEventLoop,
) -> None:
self._embedding_func = embedding_func
self._loop = loop
def _run(self, texts: list[str], context: str) -> list[list[float]]:
future = asyncio.run_coroutine_threadsafe(
self._embedding_func(texts, context=context),
self._loop,
)
result = future.result()
return [list(map(float, vec)) for vec in result]
def embed_documents(self, texts: list[str]) -> list[list[float]]:
return self._run(list(texts), context="document")
def embed_query(self, text: str) -> list[float]:
return self._run([text], context="query")[0]
def _sentence_spans(text: str, sentences: list[str]) -> list[tuple[int, int]]:
spans: list[tuple[int, int]] = []
cursor = 0
for sentence in sentences:
if not sentence:
spans.append((cursor, cursor))
continue
start = text.find(sentence, cursor)
if start < 0:
start = text.find(sentence)
if start < 0:
start = cursor
end = start + len(sentence)
spans.append((start, end))
cursor = end
return spans
def _trim_span(text: str, start: int, end: int) -> tuple[int, int]:
start = max(0, min(start, len(text)))
end = max(start, min(end, len(text)))
while start < end and text[start].isspace():
start += 1
while end > start and text[end - 1].isspace():
end -= 1
return start, end
def _semantic_groups_with_spans(
splitter: SemanticChunkerType,
text: str,
) -> list[tuple[str, int, int]]:
"""Mirror SemanticChunker grouping while keeping original source spans.
.. warning::
This re-implements the body of ``SemanticChunker.split_text`` so each group
carries its exact source span (``text[start:end]``) instead of the upstream
``" ".join(sentences)`` reflow. It relies on **private** members
(``sentence_split_regex``, ``breakpoint_threshold_type``, ``min_chunk_size``,
``number_of_chunks``, ``_calculate_sentence_distances``,
``_threshold_from_clusters``, ``_calculate_breakpoint_threshold``). Verified
byte-for-byte against ``langchain-experimental`` 0.3.20.4.x (the range pinned
in ``pyproject.toml``: ``langchain-experimental>=0.3.2,<1``). If that pin is
widened, re-verify against the new upstream ``split_text`` —
``tests/chunker/test_chunker_semantic_vector.py`` has a drift guard that
compares this mirror's grouping to the live ``splitter.split_text`` output.
"""
single_sentences_list = re.split(splitter.sentence_split_regex, text)
spans = _sentence_spans(text, single_sentences_list)
def _group(start_index: int, end_index: int) -> tuple[str, int, int] | None:
start, _ = spans[start_index]
_, end = spans[end_index]
start, end = _trim_span(text, start, end)
if start >= end:
return None
return text[start:end], start, end
if len(single_sentences_list) == 1:
group = _group(0, 0)
return [group] if group else []
if (
splitter.breakpoint_threshold_type == "gradient"
and len(single_sentences_list) == 2
):
return [g for i in range(2) if (g := _group(i, i)) is not None]
distances, sentences = splitter._calculate_sentence_distances(single_sentences_list)
if splitter.number_of_chunks is not None:
breakpoint_distance_threshold = splitter._threshold_from_clusters(distances)
breakpoint_array = distances
else:
breakpoint_distance_threshold, breakpoint_array = (
splitter._calculate_breakpoint_threshold(distances)
)
indices_above_thresh = [
i for i, x in enumerate(breakpoint_array) if x > breakpoint_distance_threshold
]
chunks: list[tuple[str, int, int]] = []
start_index = 0
for index in indices_above_thresh:
end_index = index
group_sentences = sentences[start_index : end_index + 1]
combined_text = " ".join([d["sentence"] for d in group_sentences])
if (
splitter.min_chunk_size is not None
and len(combined_text) < splitter.min_chunk_size
):
continue
group = _group(start_index, end_index)
if group is not None:
chunks.append(group)
start_index = index + 1
if start_index < len(sentences):
group = _group(start_index, len(sentences) - 1)
if group is not None:
chunks.append(group)
return chunks
async def chunking_by_semantic_vector(
tokenizer: Tokenizer,
content: str,
chunk_token_size: int = 1200,
*,
embedding_func: EmbeddingFunc | None = None,
breakpoint_threshold_type: str = "percentile",
breakpoint_threshold_amount: float | None = None,
buffer_size: int = 1,
sentence_split_regex: str = DEFAULT_SENTENCE_SPLIT_REGEX,
number_of_chunks: int | None = None,
min_chunk_size: int | None = None,
) -> list[dict[str, Any]]:
"""Semantic vector chunker — the ``"V"`` chunking strategy.
Args:
tokenizer: LightRAG tokenizer (used for output token counts).
content: Text to split.
chunk_token_size: Hard upper bound (tokens). SemanticChunker does
NOT enforce a maximum natively, so any piece that exceeds
this value is re-split via
:func:`chunking_by_recursive_character` before being emitted.
embedding_func: LightRAG :class:`EmbeddingFunc`. When ``None``
this chunker logs a warning and falls back to
:func:`chunking_by_recursive_character`.
breakpoint_threshold_type: ``percentile`` | ``standard_deviation``
| ``interquartile`` | ``gradient`` (LangChain default:
``percentile``).
breakpoint_threshold_amount: Threshold magnitude. ``None`` lets
LangChain pick the per-type default (e.g. 95 for percentile).
buffer_size: Number of adjacent sentences combined when computing
distances (LangChain default: 1).
sentence_split_regex: Pattern fed to LangChain's
:class:`SemanticChunker` for the initial sentence split.
Default extends the upstream English-only pattern with
Chinese sentence terminators ``。?!`` so mixed-language and
pure-Chinese inputs split correctly.
number_of_chunks: Optional target chunk count (LangChain SemanticChunker).
min_chunk_size: Optional minimum character size for semantic groups.
Returns:
Ordered list of ``{"tokens", "content", "chunk_order_index"}``
dicts.
"""
if not content or not content.strip():
return []
if embedding_func is None:
# V's only differentiator is embeddings — without them the
# closest neighbour is R's structural splitting. V chunks are
# non-overlapping by design (semantic boundaries), so the
# fallback uses ``chunk_overlap_token_size=0`` to preserve that
# semantic and avoid LangChain's "overlap > chunk_size" guard
# for very small ``chunk_token_size``.
logger.warning(
"[semantic_vector] embedding_func is None; falling back to "
"recursive-character chunking."
)
from lightrag.chunker.recursive_character import (
chunking_by_recursive_character,
)
return chunking_by_recursive_character(
tokenizer,
content,
chunk_token_size,
chunk_overlap_token_size=0,
)
if not _LANGCHAIN_EXPERIMENTAL_AVAILABLE:
raise ImportError(
"langchain-experimental is required for the 'V' chunking "
"strategy; install with `pip install langchain-experimental>=0.3.2`."
)
loop = asyncio.get_running_loop()
adapter = _AsyncEmbeddingFuncAdapter(embedding_func, loop)
chunker_kwargs: dict[str, Any] = {
"embeddings": adapter,
"buffer_size": int(buffer_size),
"breakpoint_threshold_type": breakpoint_threshold_type,
"sentence_split_regex": sentence_split_regex,
"number_of_chunks": number_of_chunks,
"min_chunk_size": min_chunk_size,
}
if breakpoint_threshold_amount is not None:
chunker_kwargs["breakpoint_threshold_amount"] = float(
breakpoint_threshold_amount
)
splitter = SemanticChunker(**chunker_kwargs)
pieces = await asyncio.to_thread(_semantic_groups_with_spans, splitter, content)
# SemanticChunker has no internal size cap; oversized pieces here
# would otherwise rely on the embedding-time hard fallback (which
# uses ``embedding_token_limit``, not ``chunk_token_size``) to split
# them. Enforce ``chunk_token_size`` directly via R for any piece
# that exceeds it so the user-configured size is actually honored.
# Lazy import dodges the recursive_character ↔ semantic_vector
# circular dependency (same pattern as the embedding-None fallback
# above).
from lightrag.chunker.recursive_character import (
chunking_by_recursive_character,
)
target_max = max(int(chunk_token_size), 1)
results: list[dict[str, Any]] = []
for piece, source_start, source_end in pieces:
body = piece.strip()
if not body:
continue
piece_tokens = len(tokenizer.encode(body))
if piece_tokens <= target_max:
results.append(
{
"tokens": piece_tokens,
"content": body,
"chunk_order_index": len(results),
"_source_span": {
"start": source_start,
"end": source_end,
},
}
)
continue
# Oversized semantic piece: re-split via R while preserving the
# surrounding chunk order. ``chunk_overlap_token_size=0`` keeps
# V's non-overlapping semantics.
sub_pieces = chunking_by_recursive_character(
tokenizer,
body,
target_max,
chunk_overlap_token_size=0,
)
for sub in sub_pieces:
sub_body = sub.get("content", "")
if not sub_body:
continue
sub_span = sub.get("_source_span")
source_span = None
if isinstance(sub_span, dict):
try:
source_span = {
"start": source_start + int(sub_span["start"]),
"end": source_start + int(sub_span["end"]),
}
except (KeyError, TypeError, ValueError):
source_span = None
results.append(
{
"tokens": sub.get("tokens", len(tokenizer.encode(sub_body))),
"content": sub_body,
"chunk_order_index": len(results),
**({"_source_span": source_span} if source_span else {}),
}
)
return results
+263
View File
@@ -0,0 +1,263 @@
"""Fixed-size token-window chunking — the LightRAG default strategy.
Chunks the input text into windows of at most ``chunk_token_size`` tokens
with ``chunk_overlap_token_size`` of overlap between adjacent windows.
When ``split_by_character`` is supplied, the splitter first segments on
that delimiter and then either tokenizes each segment as-is
(``split_by_character_only=True``) or further sub-splits any segment
that exceeds the token cap.
Two entry points are exported:
- :func:`chunking_by_token_size` — the **legacy 6-arg signature**
used as the default value for :attr:`lightrag.LightRAG.chunking_func`.
Kept for backward compatibility so externally-supplied chunking
functions can continue to drop in unchanged.
- :func:`chunking_by_fixed_token` — the same algorithm exposed under
the **new file-chunker contract** (standard prefix
``(tokenizer, content, chunk_token_size)`` plus keyword-only
knobs). Used by the file-based chunking dispatcher in
``process_single_document`` for ``doc_process_opts.chunking == "F"``.
"""
from __future__ import annotations
from typing import Any
from lightrag.exceptions import ChunkTokenLimitExceededError
from lightrag.utils import Tokenizer, logger
def _trimmed_span(content: str, start: int, end: int) -> tuple[int, int]:
"""Return the source span after applying the chunker's ``.strip()``."""
start = max(0, min(start, len(content)))
end = max(start, min(end, len(content)))
while start < end and content[start].isspace():
start += 1
while end > start and content[end - 1].isspace():
end -= 1
return start, end
def _source_span(content: str, start: int, end: int) -> dict[str, int] | None:
start, end = _trimmed_span(content, start, end)
if start >= end:
return None
return {"start": start, "end": end}
def _token_window_source_span(
tokenizer: Tokenizer,
content: str,
tokens: list[int],
start_token: int,
end_token: int,
*,
anchor: tuple[int, int],
) -> tuple[dict[str, int] | None, tuple[int, int]]:
"""Map a decoded token window back to its exact source span.
``anchor`` is the previous window's *verified* ``(start_token, start_char)``.
Window starts are monotonically increasing, so instead of re-decoding the whole
``tokens[:start_token]`` prefix (O(N) per window → O(N²) overall) we decode only
the delta ``tokens[anchor_token:start_token]`` (≈ one chunking step) to predict
the start char. The predicted offset is then verified against ``content`` exactly
as a full prefix decode would be: byte-level BPE decode is non-concatenative at a
multi-byte UTF-8 boundary, so a delta can be off by the few chars of one split
char — the ±32 ``find`` fallback corrects that, and re-anchoring on the verified
position each call keeps the error from accumulating. Net cost is O(N) total
while the located span stays byte-exact.
Returns ``(span, new_anchor)``. On an unlocatable (U+FFFD) window the span is
``None`` and the anchor is returned unchanged so the next window still predicts
from the last verified position.
"""
anchor_token, anchor_char = anchor
window = tokenizer.decode(tokens[start_token:end_token])
if start_token >= anchor_token:
start = anchor_char + len(tokenizer.decode(tokens[anchor_token:start_token]))
else: # non-monotonic caller (not expected) — fall back to a full prefix decode
start = len(tokenizer.decode(tokens[:start_token]))
end = start + len(window)
if content[start:end] != window:
found = content.find(
window,
max(0, start - 32),
min(len(content), end + 32 + len(window)),
)
if found < 0:
return None, anchor
start = found
end = found + len(window)
return _source_span(content, start, end), (start_token, start)
def _make_chunk(
*,
content: str,
tokens: int,
order: int,
source_span: dict[str, int] | None,
emit_source_span: bool,
) -> dict[str, Any]:
item: dict[str, Any] = {
"tokens": tokens,
"content": content.strip(),
"chunk_order_index": order,
}
if emit_source_span and source_span is not None:
item["_source_span"] = source_span
return item
def chunking_by_token_size(
tokenizer: Tokenizer,
content: str,
split_by_character: str | None = None,
split_by_character_only: bool = False,
chunk_overlap_token_size: int = 100,
chunk_token_size: int = 1200,
*,
_emit_source_span: bool = False,
) -> list[dict[str, Any]]:
"""Legacy 6-arg fixed-token chunker (default for ``LightRAG.chunking_func``).
Signature is preserved for backward compatibility with externally
supplied ``chunking_func`` implementations. New file-based chunking
dispatch uses :func:`chunking_by_fixed_token` instead.
"""
tokens = tokenizer.encode(content)
results: list[dict[str, Any]] = []
if split_by_character:
raw_chunks = content.split(split_by_character)
raw_spans: list[tuple[int, int]] = []
cursor = 0
for raw_chunk in raw_chunks:
start = cursor
end = start + len(raw_chunk)
raw_spans.append((start, end))
cursor = end + len(split_by_character)
new_chunks = []
if split_by_character_only:
for chunk, (chunk_start, chunk_end) in zip(raw_chunks, raw_spans):
_tokens = tokenizer.encode(chunk)
if len(_tokens) > chunk_token_size:
logger.warning(
"Chunk split_by_character exceeds token limit: len=%d limit=%d",
len(_tokens),
chunk_token_size,
)
raise ChunkTokenLimitExceededError(
chunk_tokens=len(_tokens),
chunk_token_limit=chunk_token_size,
chunk_preview=chunk[:120],
)
span = (
_source_span(content, chunk_start, chunk_end)
if _emit_source_span
else None
)
new_chunks.append((len(_tokens), chunk, span))
else:
for chunk, (chunk_start, chunk_end) in zip(raw_chunks, raw_spans):
_tokens = tokenizer.encode(chunk)
if len(_tokens) > chunk_token_size:
# Anchor is chunk-relative (offsets are shifted by chunk_start
# below), so it resets per split-by-character segment.
anchor = (0, 0)
for start in range(
0, len(_tokens), chunk_token_size - chunk_overlap_token_size
):
end_token = min(start + chunk_token_size, len(_tokens))
chunk_content = tokenizer.decode(_tokens[start:end_token])
span = None
if _emit_source_span:
span, anchor = _token_window_source_span(
tokenizer,
chunk,
_tokens,
start,
end_token,
anchor=anchor,
)
if span is not None:
span = {
"start": chunk_start + span["start"],
"end": chunk_start + span["end"],
}
new_chunks.append(
(
min(chunk_token_size, len(_tokens) - start),
chunk_content,
span,
)
)
else:
span = (
_source_span(content, chunk_start, chunk_end)
if _emit_source_span
else None
)
new_chunks.append((len(_tokens), chunk, span))
for index, (_len, chunk, span) in enumerate(new_chunks):
results.append(
_make_chunk(
content=chunk,
tokens=_len,
order=index,
source_span=span,
emit_source_span=_emit_source_span,
)
)
else:
anchor = (0, 0)
for index, start in enumerate(
range(0, len(tokens), chunk_token_size - chunk_overlap_token_size)
):
end = min(start + chunk_token_size, len(tokens))
chunk_content = tokenizer.decode(tokens[start:end])
span = None
if _emit_source_span:
span, anchor = _token_window_source_span(
tokenizer, content, tokens, start, end, anchor=anchor
)
results.append(
_make_chunk(
content=chunk_content,
tokens=min(chunk_token_size, len(tokens) - start),
order=index,
source_span=span,
emit_source_span=_emit_source_span,
)
)
return results
def chunking_by_fixed_token(
tokenizer: Tokenizer,
content: str,
chunk_token_size: int = 1200,
*,
chunk_overlap_token_size: int = 100,
split_by_character: str | None = None,
split_by_character_only: bool = False,
_emit_source_span: bool = False,
) -> list[dict[str, Any]]:
"""Fixed-token chunker — file-chunker contract for the ``"F"`` strategy.
Implements the same fixed-window algorithm as
:func:`chunking_by_token_size`, exposed under the standard
file-chunker signature ``(tokenizer, content, chunk_token_size, *,
<strategy kwargs>)`` so the file-based chunking dispatcher in
``process_single_document`` can call every strategy uniformly.
"""
return chunking_by_token_size(
tokenizer,
content,
split_by_character=split_by_character,
split_by_character_only=split_by_character_only,
chunk_overlap_token_size=chunk_overlap_token_size,
chunk_token_size=chunk_token_size,
_emit_source_span=_emit_source_span,
)
+337
View File
@@ -0,0 +1,337 @@
"""
Centralized configuration constants for LightRAG.
This module defines default values for configuration constants used across
different parts of the LightRAG system. Centralizing these values ensures
consistency and makes maintenance easier.
"""
from typing import Literal, TypeAlias
# Default values for server settings
DEFAULT_WOKERS = 2
DEFAULT_MAX_GRAPH_NODES = 1000
# Default values for extraction settings
DEFAULT_SUMMARY_LANGUAGE = "English" # Default language for document processing
DEFAULT_MAX_GLEANING = 1
DEFAULT_ENTITY_NAME_MAX_LENGTH = 256
# Max UTF-8 byte length for entity identifiers. Milvus enforces VARCHAR
# max_length in BYTES (not characters), so a CJK name within the character
# limit can still exceed the field limit. MUST stay <= the max_length of the
# entity_name / src_id / tgt_id fields in lightrag/kg/milvus_impl.py.
DEFAULT_ENTITY_NAME_MAX_BYTES = 512
# Per-response output limits for entity extraction prompts
DEFAULT_MAX_EXTRACTION_RECORDS = 100
DEFAULT_MAX_EXTRACTION_ENTITIES = 40
# Number of description fragments to trigger LLM summary
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE = 8
# Max description token size to trigger LLM summary
DEFAULT_SUMMARY_MAX_TOKENS = 1200
# Recommended LLM summary output length in tokens
DEFAULT_SUMMARY_LENGTH_RECOMMENDED = 600
# Maximum token size sent to LLM for summary
DEFAULT_SUMMARY_CONTEXT_SIZE = 12000
# Maximum token size allowed for entity extraction input context
DEFAULT_MAX_EXTRACT_INPUT_TOKENS = 20480
# Maximum token size for the per-chunk `---Section Context---` heading
# breadcrumb injected into the extraction prompt. Keeps section metadata from
# pushing an otherwise-valid chunk past the provider context window; over budget
# the breadcrumb collapses to ``first → … → leaf`` (top-level + nearest section).
DEFAULT_MAX_SECTION_CONTEXT_TOKENS = 256
# Per-level character cap for each heading in that breadcrumb. Must stay below
# 1/3 of DEFAULT_MAX_SECTION_CONTEXT_TOKENS so the collapsed two-level form
# (first + leaf, plus separator/ellipsis) always fits within the token budget.
DEFAULT_HEADING_LEVEL_MAX_CHARS = 80
# Separator for: description, source_id and relation-key fields(Can not be changed after data inserted)
GRAPH_FIELD_SEP = "<SEP>"
# Query and retrieval configuration defaults
DEFAULT_TOP_K = 40
DEFAULT_CHUNK_TOP_K = 20
DEFAULT_MAX_ENTITY_TOKENS = 6000
DEFAULT_MAX_RELATION_TOKENS = 8000
DEFAULT_MAX_TOTAL_TOKENS = 30000
DEFAULT_COSINE_THRESHOLD = 0.2
DEFAULT_RELATED_CHUNK_NUMBER = 5
DEFAULT_KG_CHUNK_PICK_METHOD = "VECTOR"
# Rerank configuration defaults
DEFAULT_MIN_RERANK_SCORE = 0.0
DEFAULT_RERANK_BINDING = "null"
# Default source ids limit in meta data for entity and relation
DEFAULT_MAX_SOURCE_IDS_PER_ENTITY = 200
DEFAULT_MAX_SOURCE_IDS_PER_RELATION = 200
### control chunk_ids limitation method: FIFO, FIFO
### FIFO: First in first out
### KEEP: Keep oldest (less merge action and faster)
SOURCE_IDS_LIMIT_METHOD_KEEP = "KEEP"
SOURCE_IDS_LIMIT_METHOD_FIFO = "FIFO"
DEFAULT_SOURCE_IDS_LIMIT_METHOD = SOURCE_IDS_LIMIT_METHOD_KEEP
VALID_SOURCE_IDS_LIMIT_METHODS = {
SOURCE_IDS_LIMIT_METHOD_KEEP,
SOURCE_IDS_LIMIT_METHOD_FIFO,
}
# Maximum number of file paths stored in entity/relation file_path field (For displayed only, does not affect query performance)
DEFAULT_MAX_FILE_PATHS = 75
# Field length of file_path in Milvus Schema for entity and relation (Should not be changed)
# file_path must store all file paths up to the DEFAULT_MAX_FILE_PATHS limit within the metadata.
DEFAULT_MAX_FILE_PATH_LENGTH = 32768
# Placeholder for more file paths in meta data for entity and relation (Should not be changed)
DEFAULT_FILE_PATH_MORE_PLACEHOLDER = "truncated"
# Default temperature for LLM
DEFAULT_TEMPERATURE = 1.0
# Async configuration defaults
DEFAULT_MAX_ASYNC = 4 # Default maximum async operations
DEFAULT_MAX_PARALLEL_INSERT = 3 # Default maximum parallel insert operations
# Chunker defaults — i18n-aware so Chinese / mixed-language documents
# split correctly out of the box. Override per deployment via
# CHUNK_R_SEPARATORS / CHUNK_V_SENTENCE_SPLIT_REGEX env vars.
#
# DEFAULT_R_SEPARATORS: cascade tried by langchain RecursiveCharacterTextSplitter.
# Order matters — strongest boundary first: paragraph (\n\n) > line (\n) >
# Chinese sentence-end (。!?) > Chinese semi-clause (;,) > space > char.
# English sentence-ending punctuation (.?!) is intentionally NOT included
# because RecursiveCharacterTextSplitter does literal-string splitting, so
# "." would also split numerals (``0.95``) and abbreviations (``e.g.``).
# The English path falls through space / char as before.
DEFAULT_R_SEPARATORS: tuple[str, ...] = (
"\n\n",
"\n",
"",
"",
"",
"",
"",
" ",
"",
)
# DEFAULT_SENTENCE_SPLIT_REGEX: pattern fed to langchain SemanticChunker.
# Two alternates so the English branch keeps its ``\s+`` requirement
# (avoiding ``0.95`` mid-token splits) while the Chinese branch matches
# bare ``。?!`` (CJK has no inter-sentence whitespace).
DEFAULT_SENTENCE_SPLIT_REGEX = r"(?<=[.?!])\s+|(?<=[。?!])"
# DEFAULT_CHUNK_P_SIZE: paragraph-semantic chunker target size when
# CHUNK_P_SIZE env is unset. Deliberately larger than the global
# CHUNK_SIZE default — heading-aligned paragraph merging needs more
# headroom to keep semantically related paragraphs together; falling
# back to CHUNK_SIZE (1200) would force premature splits and defeat
# the strategy's purpose.
DEFAULT_CHUNK_P_SIZE = 2000
# Paragraph-semantic "drop references" detection defaults (the chunking="P"
# drop_references option). DEFAULT_P_REFERENCES_TAIL_N: a reference block is
# only dropped when it sits within the last N content blocks of the document
# (a safety window so a mid-document "References" subsection is not removed).
# DEFAULT_P_REFERENCES_HEADINGS: heading prefixes that mark a reference
# section — English words matched case-insensitively at a word boundary,
# the Chinese "参考文献" matched as a plain prefix. Both are tunable via env
# (CHUNK_P_REFERENCES_TAIL_N / CHUNK_P_REFERENCES_HEADINGS, the latter
# pipe-separated) read live by the chunker at run time.
DEFAULT_P_REFERENCES_TAIL_N = 2
DEFAULT_P_REFERENCES_HEADINGS = ("References", "Bibliography", "参考文献")
# LightRAG Document pipeline
FULL_DOCS_FORMAT_RAW = "raw" # content in full_docs["content"]
# Post-parse persistence marker: full_docs rows written by the parsers carry
# this parse_format; on resume/retry they route to ReuseParser. Not a valid
# enqueue docs_format (the 'lightrag' ingestion entrypoint was removed).
FULL_DOCS_FORMAT_LIGHTRAG = "lightrag" # content in LightRAG Document files
FULL_DOCS_FORMAT_PENDING_PARSE = (
"pending_parse" # file saved but not yet parsed; parse_native will read from disk
)
# Marker prefix for full_docs.content when format=lightrag.
# Per docs/FileProcessingConfiguration-zh.md, the content is "{{LRdoc}}" + a
# leading summary of the parsed document so paginated APIs can show a real
# preview without loading the full LightRAG Document file.
LIGHTRAG_DOC_CONTENT_PREFIX = "{{LRdoc}}"
# Engine identifier strings (registry keys). The set of user-selectable
# engines and their suffix capabilities now live in
# lightrag.parser.registry (ParserSpec table) — the single source of truth.
PARSER_ENGINE_LEGACY = "legacy"
PARSER_ENGINE_NATIVE = "native"
PARSER_ENGINE_MINERU = "mineru"
PARSER_ENGINE_DOCLING = "docling"
PARSED_DIR_NAME = "__parsed__" # Dir for parsed files (renamed from __enqueued__)
# Prefix marking a doc_status content_summary as GENERATED from a file
# extraction error (enqueue-time error documents and parse-stage FAILED
# upserts). Doubles as the match sentinel that lets a later failure replace
# a stale generated summary while real raw-document summaries are preserved —
# keep every producer on this constant so the match never drifts.
FILE_EXTRACTION_SUMMARY_PREFIX = "[File Extraction]"
# Suffixes for parser artifact subdirectories under ``<input>/__parsed__/``.
# Centralising them here keeps the sidecar writer, engine cache modules and
# the delete-path whitelist in sync — new engines should add their raw-dir
# suffix to ``PARSED_ARTIFACT_DIR_SUFFIXES`` so deletion picks them up
# automatically.
PARSED_DIR_SUFFIX = ".parsed" # spec sidecar layout (every engine)
MINERU_RAW_DIR_SUFFIX = ".mineru_raw" # preserved MinerU raw bundle
DOCLING_RAW_DIR_SUFFIX = ".docling_raw" # preserved Docling raw bundle
NATIVE_RAW_DIR_SUFFIX = ".native_raw" # native md downloaded-image cache bundle
PARSED_ARTIFACT_DIR_SUFFIXES: tuple[str, ...] = (
PARSED_DIR_SUFFIX,
MINERU_RAW_DIR_SUFFIX,
DOCLING_RAW_DIR_SUFFIX,
NATIVE_RAW_DIR_SUFFIX,
)
# Per-file processing options carried by filename hints / LIGHTRAG_PARSER rules.
# See docs/FileProcessingConfiguration-zh.md for the full specification.
PROCESS_OPTION_IMAGES = "i" # Enable VLM analysis for drawings/images
PROCESS_OPTION_TABLES = "t" # Enable VLM analysis for tables
PROCESS_OPTION_EQUATIONS = "e" # Enable VLM analysis for equations
PROCESS_OPTION_SKIP_KG = "!" # Skip entity/relation extraction (no KG build)
ProcessChunkingOption: TypeAlias = Literal["F", "R", "V", "P"]
PROCESS_OPTION_CHUNK_FIXED: ProcessChunkingOption = (
"F" # Fixed-length / separator chunking (default)
)
PROCESS_OPTION_CHUNK_RECURSIVE: ProcessChunkingOption = (
"R" # Recursive semantic chunking
)
PROCESS_OPTION_CHUNK_VECTOR: ProcessChunkingOption = (
"V" # Vector-driven semantic chunking
)
PROCESS_OPTION_CHUNK_PARAGRAH: ProcessChunkingOption = (
"P" # Paragrah-driven semantic chunking
)
PROCESS_OPTION_CHUNK_CHARS: frozenset[ProcessChunkingOption] = frozenset(
{
PROCESS_OPTION_CHUNK_FIXED,
PROCESS_OPTION_CHUNK_RECURSIVE,
PROCESS_OPTION_CHUNK_VECTOR,
PROCESS_OPTION_CHUNK_PARAGRAH,
}
)
SUPPORTED_PROCESS_OPTIONS = frozenset(
{
PROCESS_OPTION_IMAGES,
PROCESS_OPTION_TABLES,
PROCESS_OPTION_EQUATIONS,
PROCESS_OPTION_SKIP_KG,
PROCESS_OPTION_CHUNK_FIXED,
PROCESS_OPTION_CHUNK_RECURSIVE,
PROCESS_OPTION_CHUNK_VECTOR,
PROCESS_OPTION_CHUNK_PARAGRAH,
}
)
DEFAULT_MAX_PARALLEL_ANALYZE = 5 # Multimodal analysis (VLM) concurrency
# Per-engine parsing concurrency defaults. mineru / docling are
# resource-intensive (GPU/CPU + memory), so they default to a modest amount of
# parallelism (2); lower to 1 when resources are tight, or raise via the
# MAX_PARALLEL_PARSE_* env vars when you have spare capacity.
DEFAULT_MAX_PARALLEL_PARSE_NATIVE = 5
DEFAULT_MAX_PARALLEL_PARSE_MINERU = 2
DEFAULT_MAX_PARALLEL_PARSE_DOCLING = 2
# Staged pipeline queue size defaults.
DEFAULT_QUEUE_SIZE_PARSE = 20
DEFAULT_QUEUE_SIZE_ANALYZE = 100
DEFAULT_QUEUE_SIZE_INSERT = 4
# LLM / embedding call priority levels. Lower values run first
# (asyncio.PriorityQueue semantics); priority only orders calls *within* a
# single role queue (extract / keyword / query / vlm). These name the values
# passed as the ``_priority`` argument to the priority_limit_async_func_call
# wrapper, centralizing the magic numbers that were previously inlined at each
# call site.
#
# Query stage (interactive: query/keyword LLM calls and query-time embeddings)
# gets the highest priority so user requests stay responsive.
DEFAULT_QUERY_PRIORITY = 5
# Entity/relation description summary generation — ahead of raw extraction but
# behind interactive query work.
DEFAULT_SUMMARY_PRIORITY = 8
# Processing stage entity/relation extraction (ingestion). Also the wrapper's
# baseline default for any call that does not pass ``_priority``.
DEFAULT_PROCESSING_PRIORITY = 10
# Priority used for all multimodal analysis LLM calls. Set equal to
# DEFAULT_PROCESSING_PRIORITY so analysis and ingestion work share the EXTRACT
# queue fairly and advance evenly — otherwise a busy ingestion queue starves
# analysis tasks, stalling analysis nodes and dragging down overall throughput.
DEFAULT_MM_ANALYSIS_PRIORITY = DEFAULT_PROCESSING_PRIORITY
# Multimodal analysis / chunk thresholds
# Minimum token count retained when truncating a multimodal chunk's
# description to fit within DEFAULT_MAX_EXTRACT_INPUT_TOKENS. Falling below
# this floor leaves the description too thin to ground a useful entity
# description, so the pipeline raises instead of producing a stub.
DEFAULT_MM_CHUNK_DESCRIPTION_MIN_TOKENS = 100
# Minimum image side (width or height) in pixels accepted for VLM analysis.
# Anything smaller is treated as decorative (icons, separators, etc.) and
# written as status="skipped".
DEFAULT_MM_IMAGE_MIN_PIXEL = 64
# Embedding configuration defaults
DEFAULT_EMBEDDING_FUNC_MAX_ASYNC = 8 # Default max async for embedding functions
DEFAULT_EMBEDDING_BATCH_NUM = 10 # Default batch size for embedding computations
# Gunicorn worker timeout
DEFAULT_TIMEOUT = 300
# Default llm and embedding timeout
DEFAULT_LLM_TIMEOUT = 240
DEFAULT_EMBEDDING_TIMEOUT = 30
# Rerank async / timeout defaults
# Concurrency falls back to base MAX_ASYNC_LLM when env unset; timeout has its own
# default since reranker calls are typically much faster than full LLM generation.
DEFAULT_RERANK_MAX_ASYNC = DEFAULT_MAX_ASYNC
DEFAULT_RERANK_TIMEOUT = 30
# Cross-worker global concurrency gate (gunicorn multi-worker) defaults.
# A lease whose heartbeat is older than the TTL marks its owner as suspect;
# a suspect lease is reclaimed only after the additional grace elapses while
# the owner PID is still alive (dead PIDs are reclaimed immediately).
DEFAULT_GLOBAL_SLOT_HEARTBEAT_TTL = 20.0 # ~4x the 5s health-check heartbeat
DEFAULT_GLOBAL_SLOT_SUSPECT_GRACE = 20.0 # ~1x heartbeat TTL
# Polling backoff bounds while a worker waits for a free global slot.
# The first acquisition attempt is always immediate (backoff applies only
# after a failure). The longest-waiting live process keeps polling at the
# MIN interval so it usually claims the next freed slot (soft FIFO across
# workers); other waiters back off exponentially up to the DEFERRED cap.
# The cap stays small on purpose: when the favored waiter leaves, the
# promoted one is asleep at most one deferred period, bounding slot idling.
DEFAULT_GLOBAL_SLOT_POLL_MIN = 0.05
DEFAULT_GLOBAL_SLOT_POLL_DEFERRED_MAX = 0.4
# Waiter records not refreshed within this TTL are ignored for the
# longest-waiter ranking and reaped: a crashed or stalled poller must not
# keep occupying the favored seat (which would push every live waiter onto
# the deferred backoff and waste slots). Keep > 2x the deferred poll cap.
DEFAULT_GLOBAL_SLOT_WAITER_STALE_TTL = 1.0
# Max consecutive zombie (cancelled) queue entries a worker drains while
# holding a global slot before returning the slot to other processes.
DEFAULT_GLOBAL_SLOT_DRAIN_LIMIT = 16
# Physical queue compaction (global-limit mode only): triggered when the
# estimated zombie count exceeds the threshold; each maintenance pass
# processes at most the batch limit to keep the event loop responsive.
DEFAULT_ZOMBIE_COMPACT_THRESHOLD = 64
DEFAULT_COMPACT_BATCH_LIMIT = 512
# Cross-worker queue stats: snapshots older than the stale TTL (and entries
# owned by dead PIDs) are reaped during aggregation; publishes triggered by
# counter updates are debounced to the min interval.
DEFAULT_QUEUE_STATS_STALE_TTL = 15.0
DEFAULT_QUEUE_STATS_MIN_PUBLISH_INTERVAL = 0.1
# Logging configuration defaults
DEFAULT_LOG_MAX_BYTES = 10485760 # Default 10MB
DEFAULT_LOG_BACKUP_COUNT = 5 # Default 5 backups
DEFAULT_LOG_FILENAME = "lightrag.log" # Default log filename
# Ollama server configuration defaults
DEFAULT_OLLAMA_MODEL_NAME = "lightrag"
DEFAULT_OLLAMA_MODEL_TAG = "latest"
DEFAULT_OLLAMA_MODEL_SIZE = 7365960935
DEFAULT_OLLAMA_CREATED_AT = "2024-01-15T00:00:00Z"
DEFAULT_OLLAMA_DIGEST = "sha256:lightrag"
@@ -0,0 +1,496 @@
# 📊 RAGAS-based Evaluation Framework
## What is RAGAS?
**RAGAS** (Retrieval Augmented Generation Assessment) is a framework for reference-free evaluation of RAG systems using LLMs. RAGAS uses state-of-the-art evaluation metrics:
### Core Metrics
| Metric | What It Measures | Good Score |
| ------ | ---------------- | ---------- |
| **Faithfulness** | Is the answer factually accurate based on retrieved context? | > 0.80 |
| **Answer Relevance** | Is the answer relevant to the user's question? | > 0.80 |
| **Context Recall** | Was all relevant information retrieved from documents? | > 0.80 |
| **Context Precision** | Is retrieved context clean without irrelevant noise? | > 0.80 |
| **RAGAS Score** | Overall quality metric (average of above) | > 0.80 |
### 📁 LightRAG Evalua'tion Framework Directory Structure
```
lightrag/evaluation/
├── eval_rag_quality.py # Main evaluation script
├── sample_dataset.json # 3 test questions about LightRAG
├── sample_documents/ # Matching markdown files for testing
│ ├── 01_lightrag_overview.md
│ ├── 02_rag_architecture.md
│ ├── 03_lightrag_improvements.md
│ ├── 04_supported_databases.md
│ ├── 05_evaluation_and_deployment.md
│ └── README.md
├── __init__.py # Package init
├── results/ # Output directory
│ ├── results_YYYYMMDD_HHMMSS.json # Raw metrics in JSON
│ └── results_YYYYMMDD_HHMMSS.csv # Metrics in CSV format
└── README.md # This file
```
**Quick Test:** Index files from `sample_documents/` into LightRAG, then run the evaluator to reproduce results (~89-100% RAGAS score per question).
## 🚀 Quick Start
### 1. Install Dependencies
```bash
pip install ragas datasets langfuse
```
Or use your project dependencies (already included in pyproject.toml):
```bash
pip install -e ".[evaluation]"
```
### 2. Run Evaluation
**Optional offline sample retrieval check (no API/model calls):**
```bash
python lightrag/evaluation/offline_retrieval_check.py --strict
```
This checks whether the bundled sample questions can lexically retrieve their
expected sample documents before running LightRAG, embeddings, LLM calls, or
RAGAS.
**Basic usage (uses defaults):**
```bash
cd /path/to/LightRAG
python lightrag/evaluation/eval_rag_quality.py
```
**Specify custom dataset:**
```bash
python lightrag/evaluation/eval_rag_quality.py --dataset my_test.json
```
**Specify custom RAG endpoint:**
```bash
python lightrag/evaluation/eval_rag_quality.py --ragendpoint http://my-server.com:9621
```
**Specify both (short form):**
```bash
python lightrag/evaluation/eval_rag_quality.py -d my_test.json -r http://localhost:9621
```
**Get help:**
```bash
python lightrag/evaluation/eval_rag_quality.py --help
```
### 3. View Results
Results are saved automatically in `lightrag/evaluation/results/`:
```
results/
├── results_20241023_143022.json ← Raw metrics in JSON format
└── results_20241023_143022.csv ← Metrics in CSV format (for spreadsheets)
```
**Results include:**
- ✅ Overall RAGAS score
- 📊 Per-metric averages (Faithfulness, Answer Relevance, Context Recall, Context Precision)
- 📋 Individual test case results
- 📈 Performance breakdown by question
## 📋 Command-Line Arguments
The evaluation script supports command-line arguments for easy configuration:
| Argument | Short | Default | Description |
| -------- | ----- | ------- | ----------- |
| `--dataset` | `-d` | `sample_dataset.json` | Path to test dataset JSON file |
| `--ragendpoint` | `-r` | `http://localhost:9621` or `$LIGHTRAG_API_URL` | LightRAG API endpoint URL |
### Usage Examples
**Use default dataset and endpoint:**
```bash
python lightrag/evaluation/eval_rag_quality.py
```
**Custom dataset with default endpoint:**
```bash
python lightrag/evaluation/eval_rag_quality.py --dataset path/to/my_dataset.json
```
**Default dataset with custom endpoint:**
```bash
python lightrag/evaluation/eval_rag_quality.py --ragendpoint http://my-server.com:9621
```
**Custom dataset and endpoint:**
```bash
python lightrag/evaluation/eval_rag_quality.py -d my_dataset.json -r http://localhost:9621
```
**Absolute path to dataset:**
```bash
python lightrag/evaluation/eval_rag_quality.py -d /path/to/custom_dataset.json
```
**Show help message:**
```bash
python lightrag/evaluation/eval_rag_quality.py --help
```
## ⚙️ Configuration
### Environment Variables
The evaluation framework supports customization through environment variables:
**⚠️ IMPORTANT: Both LLM and Embedding endpoints MUST be OpenAI-compatible**
- The RAGAS framework requires OpenAI-compatible API interfaces
- Custom endpoints must implement the OpenAI API format (e.g., vLLM, SGLang, LocalAI)
- Non-compatible endpoints will cause evaluation failures
| Variable | Default | Description |
| -------- | ------- | ----------- |
| **LLM Configuration** | | |
| `EVAL_LLM_MODEL` | `gpt-4o-mini` | LLM model used for RAGAS evaluation |
| `EVAL_LLM_BINDING_API_KEY` | falls back to `OPENAI_API_KEY` | API key for LLM evaluation |
| `EVAL_LLM_BINDING_HOST` | (optional) | Custom OpenAI-compatible endpoint URL for LLM |
| **Embedding Configuration** | | |
| `EVAL_EMBEDDING_MODEL` | `text-embedding-3-large` | Embedding model for evaluation |
| `EVAL_EMBEDDING_BINDING_API_KEY` | falls back to `EVAL_LLM_BINDING_API_KEY``OPENAI_API_KEY` | API key for embeddings |
| `EVAL_EMBEDDING_BINDING_HOST` | falls back to `EVAL_LLM_BINDING_HOST` | Custom OpenAI-compatible endpoint URL for embeddings |
| **Performance Tuning** | | |
| `EVAL_MAX_CONCURRENT` | 2 | Number of concurrent test case evaluations (1=serial) |
| `EVAL_QUERY_TOP_K` | 10 | Number of documents to retrieve per query |
| `EVAL_LLM_MAX_RETRIES` | 5 | Maximum LLM request retries |
| `EVAL_LLM_TIMEOUT` | 180 | LLM request timeout in seconds |
### Usage Examples
**Example 1: Default Configuration (OpenAI Official API)**
```bash
export OPENAI_API_KEY=sk-xxx
python lightrag/evaluation/eval_rag_quality.py
```
Both LLM and embeddings use OpenAI's official API with default models.
**Example 2: Custom Models on OpenAI**
```bash
export OPENAI_API_KEY=sk-xxx
export EVAL_LLM_MODEL=gpt-4o-mini
export EVAL_EMBEDDING_MODEL=text-embedding-3-large
python lightrag/evaluation/eval_rag_quality.py
```
**Example 3: Same Custom OpenAI-Compatible Endpoint for Both**
```bash
# Both LLM and embeddings use the same custom endpoint
export EVAL_LLM_BINDING_API_KEY=your-custom-key
export EVAL_LLM_BINDING_HOST=http://localhost:8000/v1
export EVAL_LLM_MODEL=qwen-plus
export EVAL_EMBEDDING_MODEL=BAAI/bge-m3
python lightrag/evaluation/eval_rag_quality.py
```
Embeddings automatically inherit LLM endpoint configuration.
**Example 4: Separate Endpoints (Cost Optimization)**
```bash
# Use OpenAI for LLM (high quality)
export EVAL_LLM_BINDING_API_KEY=sk-openai-key
export EVAL_LLM_MODEL=gpt-4o-mini
# No EVAL_LLM_BINDING_HOST means use OpenAI official API
# Use local vLLM for embeddings (cost-effective)
export EVAL_EMBEDDING_BINDING_API_KEY=local-key
export EVAL_EMBEDDING_BINDING_HOST=http://localhost:8001/v1
export EVAL_EMBEDDING_MODEL=BAAI/bge-m3
python lightrag/evaluation/eval_rag_quality.py
```
LLM uses OpenAI official API, embeddings use local custom endpoint.
**Example 5: Different Custom Endpoints for LLM and Embeddings**
```bash
# LLM on one OpenAI-compatible server
export EVAL_LLM_BINDING_API_KEY=key1
export EVAL_LLM_BINDING_HOST=http://llm-server:8000/v1
export EVAL_LLM_MODEL=custom-llm
# Embeddings on another OpenAI-compatible server
export EVAL_EMBEDDING_BINDING_API_KEY=key2
export EVAL_EMBEDDING_BINDING_HOST=http://embedding-server:8001/v1
export EVAL_EMBEDDING_MODEL=custom-embedding
python lightrag/evaluation/eval_rag_quality.py
```
Both use different custom OpenAI-compatible endpoints.
**Example 6: Using Environment Variables from .env File**
```bash
# Create .env file in project root
cat > .env << EOF
EVAL_LLM_BINDING_API_KEY=your-key
EVAL_LLM_BINDING_HOST=http://localhost:8000/v1
EVAL_LLM_MODEL=qwen-plus
EVAL_EMBEDDING_MODEL=BAAI/bge-m3
EOF
# Run evaluation (automatically loads .env)
python lightrag/evaluation/eval_rag_quality.py
```
### Concurrency Control & Rate Limiting
The evaluation framework includes built-in concurrency control to prevent API rate limiting issues:
**Why Concurrency Control Matters:**
- RAGAS internally makes many concurrent LLM calls for each test case
- Context Precision metric calls LLM once per retrieved document
- Without control, this can easily exceed API rate limits
**Default Configuration (Conservative):**
```bash
EVAL_MAX_CONCURRENT=2 # Serial evaluation (one test at a time)
EVAL_QUERY_TOP_K=10 # OP_K query parameter of LightRAG
EVAL_LLM_MAX_RETRIES=5 # Retry failed requests 5 times
EVAL_LLM_TIMEOUT=180 # 3-minute timeout per request
```
**Common Issues and Solutions:**
| Issue | Solution |
| ----- | -------- |
| **Warning: "LM returned 1 generations instead of 3"** | Reduce `EVAL_MAX_CONCURRENT` to 1 or decrease `EVAL_QUERY_TOP_K` |
| **Context Precision returns NaN** | Lower `EVAL_QUERY_TOP_K` to reduce LLM calls per test case |
| **Rate limit errors (429)** | Increase `EVAL_LLM_MAX_RETRIES` and decrease `EVAL_MAX_CONCURRENT` |
| **Request timeouts** | Increase `EVAL_LLM_TIMEOUT` to 180 or higher |
## 📝 Test Dataset
`sample_dataset.json` contains 3 generic questions about LightRAG. Replace with questions matching YOUR indexed documents.
**Custom Test Cases:**
```json
{
"test_cases": [
{
"question": "Your question here",
"ground_truth": "Expected answer from your data",
"project": "evaluation_project_name"
}
]
}
```
---
## 📊 Interpreting Results
### Score Ranges
- **0.80-1.00**: ✅ Excellent (Production-ready)
- **0.60-0.80**: ⚠️ Good (Room for improvement)
- **0.40-0.60**: ❌ Poor (Needs optimization)
- **0.00-0.40**: 🔴 Critical (Major issues)
### What Low Scores Mean
| Metric | Low Score Indicates |
| ------ | ------------------- |
| **Faithfulness** | Responses contain hallucinations or incorrect information |
| **Answer Relevance** | Answers don't match what users asked |
| **Context Recall** | Missing important information in retrieval |
| **Context Precision** | Retrieved documents contain irrelevant noise |
### Optimization Tips
1. **Low Faithfulness**:
- Improve entity extraction quality
- Better document chunking
- Tune retrieval temperature
2. **Low Answer Relevance**:
- Improve prompt engineering
- Better query understanding
- Check semantic similarity threshold
3. **Low Context Recall**:
- Increase retrieval `top_k` results
- Improve embedding model
- Better document preprocessing
4. **Low Context Precision**:
- Smaller, focused chunks
- Better filtering
- Improve chunking strategy
---
## 📚 Resources
- [RAGAS Documentation](https://docs.ragas.io/)
- [RAGAS GitHub](https://github.com/explodinggradients/ragas)
---
## 🐛 Troubleshooting
### "ModuleNotFoundError: No module named 'ragas'"
```bash
pip install ragas datasets
```
### "Warning: LM returned 1 generations instead of requested 3" or Context Precision NaN
**Cause**: This warning indicates API rate limiting or concurrent request overload:
- RAGAS makes multiple LLM calls per test case (faithfulness, relevancy, recall, precision)
- Context Precision calls LLM once per retrieved document (with `EVAL_QUERY_TOP_K=10`, that's 10 calls)
- Concurrent evaluation multiplies these calls: `EVAL_MAX_CONCURRENT × LLM calls per test`
**Solutions** (in order of effectiveness):
1. **Serial Evaluation** (Default):
```bash
export EVAL_MAX_CONCURRENT=1
python lightrag/evaluation/eval_rag_quality.py
```
2. **Reduce Retrieved Documents**:
```bash
export EVAL_QUERY_TOP_K=5 # Halves Context Precision LLM calls
python lightrag/evaluation/eval_rag_quality.py
```
3. **Increase Retry & Timeout**:
```bash
export EVAL_LLM_MAX_RETRIES=10
export EVAL_LLM_TIMEOUT=180
python lightrag/evaluation/eval_rag_quality.py
```
4. **Use Higher Quota API** (if available):
- Upgrade to OpenAI Tier 2+ for higher RPM limits
- Use self-hosted OpenAI-compatible service with no rate limits
### "AttributeError: 'InstructorLLM' object has no attribute 'agenerate_prompt'" or NaN results
This error occurs with RAGAS 0.3.x when LLM and Embeddings are not explicitly configured. The evaluation framework now handles this automatically by:
- Using environment variables to configure evaluation models
- Creating proper LLM and Embeddings instances for RAGAS
**Solution**: Ensure you have set one of the following:
- `OPENAI_API_KEY` environment variable (default)
- `EVAL_LLM_BINDING_API_KEY` for custom API key
The framework will automatically configure the evaluation models.
### "No sample_dataset.json found"
Make sure you're running from the project root:
```bash
cd /path/to/LightRAG
python lightrag/evaluation/eval_rag_quality.py
```
### "LightRAG query API errors during evaluation"
The evaluation uses your configured LLM (OpenAI by default). Ensure:
- API keys are set in `.env`
- Network connection is stable
### Evaluation requires running LightRAG API
The evaluator queries a running LightRAG API server at `http://localhost:9621`. Make sure:
1. LightRAG API server is running (`python lightrag/api/lightrag_server.py`)
2. Documents are indexed in your LightRAG instance
3. API is accessible at the configured URL
## 📝 Next Steps
1. Start LightRAG API server
2. Upload sample documents into LightRAG throught WebUI
3. Run `python lightrag/evaluation/eval_rag_quality.py`
4. Review results (JSON/CSV) in `results/` folder
Evaluation Result Sample:
```
INFO: ======================================================================
INFO: 🔍 RAGAS Evaluation - Using Real LightRAG API
INFO: ======================================================================
INFO: Evaluation Models:
INFO: • LLM Model: gpt-4.1
INFO: • Embedding Model: text-embedding-3-large
INFO: • Endpoint: OpenAI Official API
INFO: Concurrency & Rate Limiting:
INFO: • Query Top-K: 10 Entities/Relations
INFO: • LLM Max Retries: 5
INFO: • LLM Timeout: 180 seconds
INFO: Test Configuration:
INFO: • Total Test Cases: 6
INFO: • Test Dataset: sample_dataset.json
INFO: • LightRAG API: http://localhost:9621
INFO: • Results Directory: results
INFO: ======================================================================
INFO: 🚀 Starting RAGAS Evaluation of LightRAG System
INFO: 🔧 RAGAS Evaluation (Stage 2): 2 concurrent
INFO: ======================================================================
INFO:
INFO: ===================================================================================================================
INFO: 📊 EVALUATION RESULTS SUMMARY
INFO: ===================================================================================================================
INFO: # | Question | Faith | AnswRel | CtxRec | CtxPrec | RAGAS | Status
INFO: -------------------------------------------------------------------------------------------------------------------
INFO: 1 | How does LightRAG solve the hallucination probl... | 1.0000 | 1.0000 | 1.0000 | 1.0000 | 1.0000 | ✓
INFO: 2 | What are the three main components required in ... | 0.8500 | 0.5790 | 1.0000 | 1.0000 | 0.8573 | ✓
INFO: 3 | How does LightRAG's retrieval performance compa... | 0.8056 | 1.0000 | 1.0000 | 1.0000 | 0.9514 | ✓
INFO: 4 | What vector databases does LightRAG support and... | 0.8182 | 0.9807 | 1.0000 | 1.0000 | 0.9497 | ✓
INFO: 5 | What are the four key metrics for evaluating RA... | 1.0000 | 0.7452 | 1.0000 | 1.0000 | 0.9363 | ✓
INFO: 6 | What are the core benefits of LightRAG and how ... | 0.9583 | 0.8829 | 1.0000 | 1.0000 | 0.9603 | ✓
INFO: ===================================================================================================================
INFO:
INFO: ======================================================================
INFO: 📊 EVALUATION COMPLETE
INFO: ======================================================================
INFO: Total Tests: 6
INFO: Successful: 6
INFO: Failed: 0
INFO: Success Rate: 100.00%
INFO: Elapsed Time: 161.10 seconds
INFO: Avg Time/Test: 26.85 seconds
INFO:
INFO: ======================================================================
INFO: 📈 BENCHMARK RESULTS (Average)
INFO: ======================================================================
INFO: Average Faithfulness: 0.9053
INFO: Average Answer Relevance: 0.8646
INFO: Average Context Recall: 1.0000
INFO: Average Context Precision: 1.0000
INFO: Average RAGAS Score: 0.9425
INFO: ----------------------------------------------------------------------
INFO: Min RAGAS Score: 0.8573
INFO: Max RAGAS Score: 1.0000
```
---
**Happy Evaluating! 🚀**
+25
View File
@@ -0,0 +1,25 @@
"""
LightRAG Evaluation Module
RAGAS-based evaluation framework for assessing RAG system quality.
Usage:
from lightrag.evaluation import RAGEvaluator
evaluator = RAGEvaluator()
results = await evaluator.run()
Note: RAGEvaluator is imported lazily to avoid import errors
when ragas/datasets are not installed.
"""
__all__ = ["RAGEvaluator"]
def __getattr__(name):
"""Lazy import to avoid dependency errors when ragas is not installed."""
if name == "RAGEvaluator":
from .eval_rag_quality import RAGEvaluator
return RAGEvaluator
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Offline sanity check for the bundled LightRAG evaluation samples.
The check uses a small deterministic lexical ranker. It does not start
LightRAG, call the API server, compute embeddings, or call LLM/RAGAS services.
"""
from __future__ import annotations
import argparse
import json
import math
import re
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
EVAL_DIR = Path(__file__).resolve().parent
DEFAULT_DATASET = EVAL_DIR / "sample_dataset.json"
DEFAULT_DOCS_DIR = EVAL_DIR / "sample_documents"
DEFAULT_ORACLE = EVAL_DIR / "sample_retrieval_oracle.json"
STOPWORDS = {
"a",
"an",
"and",
"are",
"as",
"at",
"be",
"by",
"for",
"from",
"how",
"in",
"into",
"is",
"it",
"its",
"of",
"on",
"or",
"that",
"the",
"their",
"to",
"what",
"with",
}
@dataclass
class Document:
name: str
tokens: Counter[str]
@dataclass
class QueryResult:
question: str
expected: list[str]
ranked: list[str]
def recall_at(self, top_k: int) -> float:
hits = set(self.expected) & set(self.ranked[:top_k])
return len(hits) / len(self.expected)
def reciprocal_rank(self) -> float:
for rank, document in enumerate(self.ranked, start=1):
if document in self.expected:
return 1 / rank
return 0.0
def tokenize(text: str) -> list[str]:
tokens = re.findall(r"[a-z0-9]+", text.lower())
return [token for token in tokens if token not in STOPWORDS and len(token) > 1]
def load_cases(dataset_path: Path) -> list[dict[str, Any]]:
payload = json.loads(dataset_path.read_text(encoding="utf-8"))
cases = payload.get("test_cases")
if not isinstance(cases, list):
raise ValueError(f"{dataset_path} must contain a test_cases list")
return cases
def load_oracle(oracle_path: Path) -> dict[str, list[str]]:
payload = json.loads(oracle_path.read_text(encoding="utf-8"))
entries = payload.get("oracle")
if not isinstance(entries, list):
raise ValueError(f"{oracle_path} must contain an oracle list")
oracle: dict[str, list[str]] = {}
for entry in entries:
question = str(entry.get("question", "")).strip()
expected = entry.get("expected_documents")
if not question or not isinstance(expected, list) or not expected:
raise ValueError("Each oracle entry needs question and expected_documents")
oracle[question] = [str(document) for document in expected]
return oracle
def load_documents(docs_dir: Path) -> list[Document]:
documents: list[Document] = []
for path in sorted(docs_dir.glob("*.md")):
if path.name.lower() == "readme.md":
continue
documents.append(
Document(
name=path.name,
tokens=Counter(tokenize(path.read_text(encoding="utf-8"))),
)
)
if not documents:
raise ValueError(f"No markdown sample documents found in {docs_dir}")
return documents
def inverse_document_frequency(documents: list[Document]) -> dict[str, float]:
document_frequency: Counter[str] = Counter()
for document in documents:
document_frequency.update(document.tokens.keys())
doc_count = len(documents)
return {
token: math.log((doc_count + 1) / (frequency + 1)) + 1
for token, frequency in document_frequency.items()
}
def score_query(
query_tokens: list[str],
document: Document,
idf: dict[str, float],
) -> float:
score = 0.0
for token in query_tokens:
if token in document.tokens:
score += (1 + math.log(document.tokens[token])) * idf.get(token, 0.0)
return score
def audit_samples(
cases: list[dict[str, Any]],
oracle: dict[str, list[str]],
documents: list[Document],
) -> list[QueryResult]:
idf = inverse_document_frequency(documents)
results: list[QueryResult] = []
for case in cases:
question = str(case.get("question", "")).strip()
if question not in oracle:
raise ValueError(f"No oracle entry for question: {question}")
query_tokens = tokenize(question)
scored_documents = [
(score_query(query_tokens, document, idf), document)
for document in documents
]
ranked = [
document
for score, document in sorted(
scored_documents,
key=lambda item: (-item[0], item[1].name),
)
if score > 0
]
results.append(
QueryResult(
question=question,
expected=oracle[question],
ranked=[document.name for document in ranked],
)
)
return results
def summarize(results: list[QueryResult], top_k: int) -> dict[str, Any]:
if not results:
raise ValueError("No query results to summarize")
recalls = [result.recall_at(top_k) for result in results]
reciprocal_ranks = [result.reciprocal_rank() for result in results]
return {
"queries": len(results),
"top_k": top_k,
"average_recall_at_k": sum(recalls) / len(recalls),
"mean_reciprocal_rank": sum(reciprocal_ranks) / len(reciprocal_ranks),
"full_recall_queries": sum(recall == 1.0 for recall in recalls),
"no_hit_queries": sum(recall == 0.0 for recall in recalls),
}
def print_report(results: list[QueryResult], top_k: int) -> None:
summary = summarize(results, top_k)
print("LightRAG sample retrieval check")
print(f"Queries: {summary['queries']}")
print(f"Top-k: {summary['top_k']}")
print(f"Average recall@k: {summary['average_recall_at_k']:.3f}")
print(f"Mean reciprocal rank: {summary['mean_reciprocal_rank']:.3f}")
print(f"Full-recall queries: {summary['full_recall_queries']}/{summary['queries']}")
print(f"No-hit queries: {summary['no_hit_queries']}")
print()
for index, result in enumerate(results, start=1):
top_docs = ", ".join(result.ranked[:top_k])
expected = ", ".join(result.expected)
print(f"{index}. recall@{top_k}={result.recall_at(top_k):.3f}")
print(f" expected: {expected}")
print(f" top docs: {top_docs}")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run an offline retrieval check for LightRAG evaluation samples."
)
parser.add_argument("--dataset", default=str(DEFAULT_DATASET))
parser.add_argument("--docs-dir", default=str(DEFAULT_DOCS_DIR))
parser.add_argument("--oracle", default=str(DEFAULT_ORACLE))
parser.add_argument("--top-k", type=int, default=2)
parser.add_argument(
"--strict",
action="store_true",
help="Exit non-zero unless every sample query has full recall@k.",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
if args.top_k <= 0:
print("--top-k must be positive", file=sys.stderr)
return 2
try:
cases = load_cases(Path(args.dataset).expanduser())
oracle = load_oracle(Path(args.oracle).expanduser())
documents = load_documents(Path(args.docs_dir).expanduser())
results = audit_samples(cases, oracle, documents)
print_report(results, args.top_k)
summary = summarize(results, args.top_k)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"Sample retrieval check failed: {exc}", file=sys.stderr)
return 2
if args.strict and summary["full_recall_queries"] != summary["queries"]:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+34
View File
@@ -0,0 +1,34 @@
{
"test_cases": [
{
"question": "How does LightRAG solve the hallucination problem in large language models?",
"ground_truth": "LightRAG solves the hallucination problem by combining large language models with external knowledge retrieval. The framework ensures accurate responses by grounding LLM outputs in actual documents. LightRAG provides contextual responses that reduce hallucinations significantly.",
"project": "lightrag_evaluation_sample"
},
{
"question": "What are the three main components required in a RAG system?",
"ground_truth": "A RAG system requires three main components: a retrieval system (vector database or search engine) to find relevant documents, an embedding model to convert text into vector representations for similarity search, and a large language model (LLM) to generate responses based on retrieved context.",
"project": "lightrag_evaluation_sample"
},
{
"question": "How does LightRAG's retrieval performance compare to traditional RAG approaches?",
"ground_truth": "LightRAG delivers faster retrieval performance than traditional RAG approaches. The framework optimizes document retrieval operations for speed. Traditional RAG systems often suffer from slow query response times. LightRAG achieves high quality results with improved performance. The framework combines speed with accuracy in retrieval operations, prioritizing ease of use without sacrificing quality.",
"project": "lightrag_evaluation_sample"
},
{
"question": "What vector databases does LightRAG support and what are their key characteristics?",
"ground_truth": "LightRAG supports multiple vector databases including ChromaDB for simple deployment and efficient similarity search, Neo4j for graph-based knowledge representation with vector capabilities, Milvus for high-performance vector search at scale, Qdrant for fast similarity search with filtering and production-ready infrastructure, MongoDB Atlas for combined document storage and vector search, Redis for in-memory low-latency vector search, and a built-in nano-vectordb that eliminates external dependencies for small projects. This multi-database support enables developers to choose appropriate backends based on scale, performance, and infrastructure requirements.",
"project": "lightrag_evaluation_sample"
},
{
"question": "What are the four key metrics for evaluating RAG system quality and what does each metric measure?",
"ground_truth": "RAG system quality is measured through four key metrics: Faithfulness measures whether answers are factually grounded in retrieved context and detects hallucinations. Answer Relevance measures how well answers address the user question and evaluates response appropriateness. Context Recall measures completeness of retrieval and whether all relevant information was retrieved from documents. Context Precision measures quality and relevance of retrieved documents without noise or irrelevant content.",
"project": "lightrag_evaluation_sample"
},
{
"question": "What are the core benefits of LightRAG and how does it improve upon traditional RAG systems?",
"ground_truth": "LightRAG offers five core benefits: accuracy through document-grounded responses, up-to-date information without model retraining, domain expertise through specialized document collections, cost-effectiveness by avoiding expensive fine-tuning, and transparency by showing source documents. Compared to traditional RAG systems, LightRAG provides a simpler API with intuitive interfaces, faster retrieval performance with optimized operations, better integration with multiple vector database backends for flexible selection, and optimized prompting strategies with refined templates. LightRAG prioritizes ease of use while maintaining quality and combines speed with accuracy.",
"project": "lightrag_evaluation_sample"
}
]
}
@@ -0,0 +1,17 @@
# LightRAG Framework Overview
## What is LightRAG?
**LightRAG** is a Simple and Fast Retrieval-Augmented Generation framework. LightRAG was developed by HKUDS (Hong Kong University Data Science Lab). The framework provides developers with tools to build RAG applications efficiently.
## Problem Statement
Large language models face several limitations. LLMs have a knowledge cutoff date that prevents them from accessing recent information. Large language models generate hallucinations when providing responses without factual grounding. LLMs lack domain-specific expertise in specialized fields.
## How LightRAG Solves These Problems
LightRAG solves the hallucination problem by combining large language models with external knowledge retrieval. The framework ensures accurate responses by grounding LLM outputs in actual documents. LightRAG provides contextual responses that reduce hallucinations significantly. The system enables efficient retrieval from external knowledge bases to supplement LLM capabilities.
## Core Benefits
LightRAG offers accuracy through document-grounded responses. The framework provides up-to-date information without model retraining. LightRAG enables domain expertise through specialized document collections. The system delivers cost-effectiveness by avoiding expensive model fine-tuning. LightRAG ensures transparency by showing source documents for each response.
@@ -0,0 +1,21 @@
# RAG System Architecture
## Main Components of RAG Systems
A RAG system consists of three main components that work together to provide intelligent responses.
### Component 1: Retrieval System
The retrieval system is the first component of a RAG system. A retrieval system finds relevant documents from large document collections. Vector databases serve as the primary storage for the retrieval system. Search engines can also function as retrieval systems in RAG architectures.
### Component 2: Embedding Model
The embedding model is the second component of a RAG system. An embedding model converts text into vector representations for similarity search. The embedding model transforms documents and queries into numerical vectors. These vector representations enable semantic similarity matching between queries and documents.
### Component 3: Large Language Model
The large language model is the third component of a RAG system. An LLM generates responses based on retrieved context from documents. The large language model synthesizes information from multiple sources into coherent answers. LLMs provide natural language generation capabilities for the RAG system.
## How Components Work Together
The retrieval system fetches relevant documents for a user query. The embedding model enables similarity matching between query and documents. The LLM generates the final response using retrieved context. These three components collaborate to provide accurate, contextual responses.
@@ -0,0 +1,25 @@
# LightRAG Improvements Over Traditional RAG
## Key Improvements
LightRAG improves upon traditional RAG approaches in several significant ways.
### Simpler API Design
LightRAG offers a simpler API compared to traditional RAG frameworks. The framework provides intuitive interfaces for developers. Traditional RAG systems often require complex configuration and setup. LightRAG focuses on ease of use while maintaining functionality.
### Faster Retrieval Performance
LightRAG delivers faster retrieval performance than traditional RAG approaches. The framework optimizes document retrieval operations for speed. Traditional RAG systems often suffer from slow query response times. LightRAG achieves high quality results with improved performance.
### Better Vector Database Integration
LightRAG provides better integration with various vector databases. The framework supports multiple vector database backends seamlessly. Traditional RAG approaches typically lock developers into specific database choices. LightRAG enables flexible storage backend selection.
### Optimized Prompting Strategies
LightRAG implements optimized prompting strategies for better results. The framework uses refined prompt templates for accurate responses. Traditional RAG systems often use generic prompting approaches. LightRAG balances simplicity with high quality output.
## Design Philosophy
LightRAG prioritizes ease of use without sacrificing quality. The framework combines speed with accuracy in retrieval operations. LightRAG maintains flexibility in database and model selection.
@@ -0,0 +1,37 @@
# LightRAG Vector Database Support
## Supported Vector Databases
LightRAG supports multiple vector databases for flexible deployment options.
### ChromaDB
ChromaDB is a vector database supported by LightRAG. ChromaDB provides simple deployment for development environments. The database offers efficient vector similarity search capabilities.
### Neo4j
Neo4j is a graph database supported by LightRAG. Neo4j enables graph-based knowledge representation alongside vector search. The database combines relationship modeling with vector capabilities.
### Milvus
Milvus is a vector database supported by LightRAG. Milvus provides high-performance vector search at scale. The database handles large-scale vector collections efficiently.
### Qdrant
Qdrant is a vector database supported by LightRAG. Qdrant offers fast similarity search with filtering capabilities. The database provides production-ready vector search infrastructure.
### MongoDB Atlas Vector Search
MongoDB Atlas Vector Search is supported by LightRAG. MongoDB Atlas combines document storage with vector search capabilities. The database enables unified data management for RAG applications.
### Redis
Redis is supported by LightRAG for vector search operations. Redis provides in-memory vector search with low latency. The database offers fast retrieval for real-time applications.
### Built-in Nano-VectorDB
LightRAG includes a built-in nano-vectordb for simple deployments. Nano-vectordb eliminates external database dependencies for small projects. The built-in database provides basic vector search functionality without additional setup.
## Database Selection Benefits
The multiple database support enables developers to choose appropriate storage backends. LightRAG adapts to different deployment scenarios from development to production. Users can select databases based on scale, performance, and infrastructure requirements.
@@ -0,0 +1,41 @@
# RAG Evaluation Metrics and Deployment
## Key RAG Evaluation Metrics
RAG system quality is measured through four key metrics.
### Faithfulness Metric
Faithfulness measures whether answers are factually grounded in retrieved context. The faithfulness metric detects hallucinations in LLM responses. High faithfulness scores indicate answers based on actual document content. The metric evaluates factual accuracy of generated responses.
### Answer Relevance Metric
Answer Relevance measures how well answers address the user question. The answer relevance metric evaluates response quality and appropriateness. High answer relevance scores show responses that directly answer user queries. The metric assesses the connection between questions and generated answers.
### Context Recall Metric
Context Recall measures completeness of retrieval from documents. The context recall metric evaluates whether all relevant information was retrieved. High context recall scores indicate comprehensive document retrieval. The metric assesses retrieval system effectiveness.
### Context Precision Metric
Context Precision measures quality and relevance of retrieved documents. The context precision metric evaluates retrieval accuracy without noise. High context precision scores show clean retrieval without irrelevant content. The metric measures retrieval system selectivity.
## LightRAG Deployment Options
LightRAG can be deployed in production through multiple approaches.
### Docker Container Deployment
Docker containers enable consistent LightRAG deployment across environments. Docker provides isolated runtime environments for the framework. Container deployment simplifies dependency management and scaling.
### REST API Server with FastAPI
FastAPI serves as the REST API framework for LightRAG deployment. The FastAPI server exposes LightRAG functionality through HTTP endpoints. REST API deployment enables client-server architecture for RAG applications.
### Direct Python Integration
Direct Python integration embeds LightRAG into Python applications. Python integration provides programmatic access to RAG capabilities. Direct integration supports custom application workflows and pipelines.
### Deployment Features
LightRAG supports environment-based configuration for different deployment scenarios. The framework integrates with multiple LLM providers for flexibility. LightRAG enables horizontal scaling for production workloads.
@@ -0,0 +1,21 @@
# Sample Documents for Evaluation
These markdown files correspond to test questions in `../sample_dataset.json`.
## Usage
1. **Index documents** into LightRAG (via WebUI, API, or Python)
2. **Run evaluation**: `python lightrag/evaluation/eval_rag_quality.py`
3. **Expected results**: ~91-100% RAGAS score per question
## Files
- `01_lightrag_overview.md` - LightRAG framework and hallucination problem
- `02_rag_architecture.md` - RAG system components
- `03_lightrag_improvements.md` - LightRAG vs traditional RAG
- `04_supported_databases.md` - Vector database support
- `05_evaluation_and_deployment.md` - Metrics and deployment
## Note
Documents use clear entity-relationship patterns for LightRAG's default entity extraction prompts. For better results with your data, customize `lightrag/prompt.py`.
@@ -0,0 +1,31 @@
{
"oracle": [
{
"question": "How does LightRAG solve the hallucination problem in large language models?",
"expected_documents": ["01_lightrag_overview.md"]
},
{
"question": "What are the three main components required in a RAG system?",
"expected_documents": ["02_rag_architecture.md"]
},
{
"question": "How does LightRAG's retrieval performance compare to traditional RAG approaches?",
"expected_documents": ["03_lightrag_improvements.md"]
},
{
"question": "What vector databases does LightRAG support and what are their key characteristics?",
"expected_documents": ["04_supported_databases.md"]
},
{
"question": "What are the four key metrics for evaluating RAG system quality and what does each metric measure?",
"expected_documents": ["05_evaluation_and_deployment.md"]
},
{
"question": "What are the core benefits of LightRAG and how does it improve upon traditional RAG systems?",
"expected_documents": [
"01_lightrag_overview.md",
"03_lightrag_improvements.md"
]
}
]
}
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
import httpx
from typing import Literal
class APIStatusError(Exception):
"""Raised when an API response has a status code of 4xx or 5xx."""
response: httpx.Response
status_code: int
request_id: str | None
def __init__(
self, message: str, *, response: httpx.Response, body: object | None
) -> None:
super().__init__(message)
self.request = response.request
self.body = body
self.response = response
self.status_code = response.status_code
self.request_id = response.headers.get("x-request-id")
class APIConnectionError(Exception):
def __init__(
self, *, message: str = "Connection error.", request: httpx.Request | None
) -> None:
super().__init__(message)
self.request = request
class BadRequestError(APIStatusError):
status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride]
class AuthenticationError(APIStatusError):
status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride]
class PermissionDeniedError(APIStatusError):
status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride]
class NotFoundError(APIStatusError):
status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride]
class ConflictError(APIStatusError):
status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride]
class UnprocessableEntityError(APIStatusError):
status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride]
class RateLimitError(APIStatusError):
status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride]
class APITimeoutError(APIConnectionError):
def __init__(self, request: httpx.Request | None) -> None:
super().__init__(message="Request timed out.", request=request)
class StorageNotInitializedError(RuntimeError):
"""Raised when storage operations are attempted before initialization."""
def __init__(self, storage_type: str = "Storage"):
super().__init__(
f"{storage_type} not initialized. Please ensure proper initialization:\n"
f"\n"
f" rag = LightRAG(...)\n"
f" await rag.initialize_storages() # Required - auto-initializes pipeline_status\n"
f"\n"
f"See: https://github.com/HKUDS/LightRAG#important-initialization-requirements"
)
class PipelineNotInitializedError(KeyError):
"""Raised when pipeline status is accessed before initialization."""
def __init__(self, namespace: str = ""):
msg = (
f"Pipeline namespace '{namespace}' not found.\n"
f"\n"
f"Pipeline status should be auto-initialized by initialize_storages().\n"
f"If you see this error, please ensure:\n"
f"\n"
f" 1. You called await rag.initialize_storages()\n"
f" 2. For multi-workspace setups, each LightRAG instance was properly initialized\n"
f"\n"
f"Standard initialization:\n"
f" rag = LightRAG(workspace='your_workspace')\n"
f" await rag.initialize_storages() # Auto-initializes pipeline_status\n"
f"\n"
f"If you need manual control (advanced):\n"
f" from lightrag.kg.shared_storage import initialize_pipeline_status\n"
f" await initialize_pipeline_status(workspace='your_workspace')"
)
super().__init__(msg)
class PipelineCancelledException(Exception):
"""Raised when pipeline processing is cancelled by user request."""
def __init__(self, message: str = "User cancelled"):
super().__init__(message)
self.message = message
class IndexFlushError(Exception):
"""Raised when a storage backend fails to flush buffered index ops.
Carries the storage driver name and namespace so the pipeline can abort
the batch with an actionable reason. The underlying error is preserved as
the exception ``__cause__`` (set via ``raise ... from cause``).
"""
def __init__(self, storage_name: str, namespace: str, cause: BaseException):
self.storage_name = storage_name
self.namespace = namespace
super().__init__(f"{storage_name}[{namespace}] index flush failed: {cause}")
class ChunkTokenLimitExceededError(ValueError):
"""Raised when a chunk exceeds the configured token limit."""
def __init__(
self,
chunk_tokens: int,
chunk_token_limit: int,
chunk_preview: str | None = None,
) -> None:
preview = chunk_preview.strip() if chunk_preview else None
truncated_preview = preview[:80] if preview else None
preview_note = f" Preview: '{truncated_preview}'" if truncated_preview else ""
message = (
f"Chunk token length {chunk_tokens} exceeds chunk_token_size {chunk_token_limit}."
f"{preview_note}"
)
super().__init__(message)
self.chunk_tokens = chunk_tokens
self.chunk_token_limit = chunk_token_limit
self.chunk_preview = truncated_preview
class ChunkBlockMatchError(ValueError):
"""Raised when a chunk cannot be located in the document's blocks.jsonl.
Sidecar backfill (``lightrag.sidecar.backfill``) maps F/R/V chunks back to
their source block(s) by matching chunk content against the parse-time
``*.blocks.jsonl`` merged text. When a sidecar-less chunk cannot be located,
this is raised so the pipeline marks the document FAILED rather than
persisting chunks with missing/incorrect provenance.
"""
def __init__(
self,
chunk_order_index: int,
chunk_preview: str | None = None,
blocks_path: str | None = None,
) -> None:
preview = chunk_preview.strip() if chunk_preview else None
truncated_preview = preview[:80] if preview else None
preview_note = f" Preview: '{truncated_preview}'" if truncated_preview else ""
path_note = f" (blocks: {blocks_path})" if blocks_path else ""
message = (
f"Chunk #{chunk_order_index} could not be located in the document "
f"blocks during sidecar backfill.{preview_note}{path_note}"
)
super().__init__(message)
self.chunk_order_index = chunk_order_index
self.chunk_preview = truncated_preview
self.blocks_path = blocks_path
class DataMigrationError(Exception):
"""Raised when data migration from legacy collection/table fails."""
def __init__(self, message: str):
super().__init__(message)
self.message = message
class MultimodalAnalysisError(RuntimeError):
"""Raised when multimodal analysis must fail the current document.
Hard failures (missing required field, schema mismatch, model not
available, sidecar already carries ``status="failure"``) bubble this
exception so the pipeline marks the document failed instead of writing
an unusable analyze result. Callers persist a ``status="failure"``
sidecar entry alongside the raise so a re-run sees the failure.
"""
+142
View File
@@ -0,0 +1,142 @@
"""Shared atomic file-write helpers.
Why this lives at the package root rather than under ``lightrag/kg/``:
``lightrag.utils.write_json`` needs ``atomic_write`` to gain crash safety,
and several ``lightrag/kg/*`` modules need both. Hosting the helpers under
``lightrag/kg/`` would create a ``utils -> kg -> utils`` import cycle.
Keeping this module dependency-free (stdlib only) avoids that.
Semantics
---------
``atomic_write`` writes through a per-writer ``.tmp.<pid>.<tid>.<ns>`` sibling
and renames into place with ``os.replace`` — atomic on the same filesystem on
both POSIX (``rename(2)``) and Windows (``MoveFileEx`` with
``MOVEFILE_REPLACE_EXISTING``). Two failure modes are handled differently:
- A Python exception (``write_fn`` raised, ``os.replace`` failed, etc.):
``finally`` runs, the in-flight tmp is removed best-effort, and the
exception propagates. The on-disk destination is the prior snapshot.
- A process-level kill (SIGKILL, OOM, hard reboot) between writing the tmp
and the rename: ``finally`` does not run, the tmp survives as an orphan,
and ``reap_orphan_tmp_files`` cleans it on the next startup once it ages
past the threshold.
What is *not* preserved across the inode swap done by ``os.replace``: owner,
group, ACLs, xattrs, hard-link relationships, and any symlink-target identity.
The mode bits (rwx) are preserved explicitly — see ``_preserve_mode``.
"""
from __future__ import annotations
import glob
import logging
import os
import stat
import threading
import time
from typing import Callable
logger = logging.getLogger("lightrag")
# Orphan .tmp files older than this are reaped on startup. Large enough that
# an in-flight write from another live process cannot plausibly still be
# running (multi-million-node graphml writes finish in minutes, not hours).
TMP_REAP_AGE_SECONDS = 3600
def tmp_path_for(file_name: str) -> str:
"""Return a per-writer tmp sibling for ``file_name``.
The suffix embeds PID, thread id, and a nanosecond timestamp so that
multiple concurrent writers — separate processes sharing the same working
directory, or multiple threads inside one process — cannot trample each
other's in-flight tmp and leave a "no such file" rename error behind.
"""
return f"{file_name}.tmp.{os.getpid()}.{threading.get_ident()}.{time.time_ns()}"
def _preserve_mode(tmp: str, dst: str, workspace: str) -> None:
"""Carry ``dst``'s existing mode bits onto ``tmp`` before the rename.
Without this, ``os.replace`` swaps the inode and the new file inherits
umask defaults — any intentional restriction (e.g. chmod 0600) on the
prior snapshot would be silently widened.
"""
if not os.path.exists(dst):
return
try:
os.chmod(tmp, stat.S_IMODE(os.stat(dst).st_mode))
except OSError as exc:
logger.warning(f"[{workspace}] Could not preserve mode of {dst}: {exc}")
def reap_orphan_tmp_files(
file_name: str,
workspace: str = "_",
age_seconds: int = TMP_REAP_AGE_SECONDS,
extra_patterns: tuple[str, ...] = (),
) -> None:
"""Delete stale tmp siblings of ``file_name`` left behind by hard kills.
Default pattern matches ``glob.escape(file_name) + ".tmp.*"`` — the suffix
shape produced by ``tmp_path_for``. ``extra_patterns`` accepts already-built
glob patterns and is intended for migrating away from legacy naming
schemes (e.g. Faiss's previous fixed ``<meta>.tmp`` suffix, which the
default pattern's trailing ``.*`` will not match).
``glob.escape`` is required because ``file_name`` is composed from
``working_dir + namespace`` and can legitimately contain glob
metacharacters (workspace ``[v2]``, ``*``, ``?``). Concatenating naively
would silently miss the real orphan or widen the match to tmp files of
unrelated storage types.
"""
patterns = [glob.escape(file_name) + ".tmp.*", *extra_patterns]
now = time.time()
for pattern in patterns:
for path in glob.glob(pattern):
try:
age = now - os.path.getmtime(path)
except OSError:
continue
if age < age_seconds:
continue
try:
os.remove(path)
logger.info(
f"[{workspace}] Reaped orphan tmp file: {path} (age {age:.0f}s)"
)
except OSError as exc:
logger.warning(
f"[{workspace}] Failed to reap orphan tmp file {path}: {exc}"
)
def atomic_write(
file_name: str,
write_fn: Callable[[str], None],
workspace: str = "_",
) -> None:
"""Run ``write_fn(tmp_path)`` then atomically replace ``file_name`` with it.
``write_fn`` is responsible for actually producing the file contents at
the path it receives. It must not assume the tmp path equals ``file_name``
— Faiss/Nano callers rely on the tmp path being a real sibling.
On any exception from ``write_fn`` or from the rename, the tmp is removed
best-effort and the exception propagates. The destination file is not
touched in that case.
"""
tmp = tmp_path_for(file_name)
try:
write_fn(tmp)
_preserve_mode(tmp, file_name, workspace)
os.replace(tmp, file_name)
except BaseException:
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError as exc:
logger.warning(
f"[{workspace}] Failed to remove tmp after failed atomic write: {exc}"
)
raise
+161
View File
@@ -0,0 +1,161 @@
STORAGE_IMPLEMENTATIONS = {
"KV_STORAGE": {
"implementations": [
"JsonKVStorage",
"RedisKVStorage",
"PGKVStorage",
"MongoKVStorage",
"OpenSearchKVStorage",
],
"required_methods": ["get_by_id", "upsert"],
},
"GRAPH_STORAGE": {
"implementations": [
"NetworkXStorage",
"Neo4JStorage",
"PGGraphStorage",
"MongoGraphStorage",
"MemgraphStorage",
"OpenSearchGraphStorage",
],
"required_methods": ["upsert_node", "upsert_edge"],
},
"VECTOR_STORAGE": {
"implementations": [
"NanoVectorDBStorage",
"MilvusVectorDBStorage",
"PGVectorStorage",
"FaissVectorDBStorage",
"QdrantVectorDBStorage",
"MongoVectorDBStorage",
"OpenSearchVectorDBStorage",
# "ChromaVectorDBStorage",
],
"required_methods": ["query", "upsert"],
},
"DOC_STATUS_STORAGE": {
"implementations": [
"JsonDocStatusStorage",
"RedisDocStatusStorage",
"PGDocStatusStorage",
"MongoDocStatusStorage",
"OpenSearchDocStatusStorage",
],
"required_methods": ["get_docs_by_status"],
},
}
# Storage implementation environment variable without default value
STORAGE_ENV_REQUIREMENTS: dict[str, list[str]] = {
# KV Storage Implementations
"JsonKVStorage": [],
"MongoKVStorage": [
"MONGO_URI",
"MONGO_DATABASE",
],
"RedisKVStorage": ["REDIS_URI"],
"PGKVStorage": ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DATABASE"],
# Graph Storage Implementations
"NetworkXStorage": [],
"Neo4JStorage": ["NEO4J_URI", "NEO4J_USERNAME", "NEO4J_PASSWORD"],
"MongoGraphStorage": [
"MONGO_URI",
"MONGO_DATABASE",
],
"MemgraphStorage": ["MEMGRAPH_URI"],
"AGEStorage": [
"AGE_POSTGRES_DB",
"AGE_POSTGRES_USER",
"AGE_POSTGRES_PASSWORD",
],
"PGGraphStorage": [
"POSTGRES_USER",
"POSTGRES_PASSWORD",
"POSTGRES_DATABASE",
],
# Vector Storage Implementations
"NanoVectorDBStorage": [],
"MilvusVectorDBStorage": [
"MILVUS_URI",
"MILVUS_DB_NAME",
],
# "ChromaVectorDBStorage": [],
"PGVectorStorage": ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DATABASE"],
"FaissVectorDBStorage": [],
"QdrantVectorDBStorage": ["QDRANT_URL"], # QDRANT_API_KEY has default value None
"MongoVectorDBStorage": [
"MONGO_URI",
"MONGO_DATABASE",
],
# Document Status Storage Implementations
"JsonDocStatusStorage": [],
"RedisDocStatusStorage": ["REDIS_URI"],
"PGDocStatusStorage": ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DATABASE"],
"MongoDocStatusStorage": [
"MONGO_URI",
"MONGO_DATABASE",
],
# OpenSearch Storage Implementations
"OpenSearchKVStorage": [
"OPENSEARCH_HOSTS",
],
"OpenSearchDocStatusStorage": [
"OPENSEARCH_HOSTS",
],
"OpenSearchGraphStorage": [
"OPENSEARCH_HOSTS",
],
"OpenSearchVectorDBStorage": [
"OPENSEARCH_HOSTS",
],
}
# Storage implementation module mapping
STORAGES = {
"NetworkXStorage": ".kg.networkx_impl",
"JsonKVStorage": ".kg.json_kv_impl",
"NanoVectorDBStorage": ".kg.nano_vector_db_impl",
"JsonDocStatusStorage": ".kg.json_doc_status_impl",
"Neo4JStorage": ".kg.neo4j_impl",
"MilvusVectorDBStorage": ".kg.milvus_impl",
"MongoKVStorage": ".kg.mongo_impl",
"MongoDocStatusStorage": ".kg.mongo_impl",
"MongoGraphStorage": ".kg.mongo_impl",
"MongoVectorDBStorage": ".kg.mongo_impl",
"RedisKVStorage": ".kg.redis_impl",
"RedisDocStatusStorage": ".kg.redis_impl",
"ChromaVectorDBStorage": ".kg.chroma_impl",
"PGKVStorage": ".kg.postgres_impl",
"PGVectorStorage": ".kg.postgres_impl",
"AGEStorage": ".kg.age_impl",
"PGGraphStorage": ".kg.postgres_impl",
"PGDocStatusStorage": ".kg.postgres_impl",
"FaissVectorDBStorage": ".kg.faiss_impl",
"QdrantVectorDBStorage": ".kg.qdrant_impl",
"MemgraphStorage": ".kg.memgraph_impl",
"OpenSearchKVStorage": ".kg.opensearch_impl",
"OpenSearchDocStatusStorage": ".kg.opensearch_impl",
"OpenSearchGraphStorage": ".kg.opensearch_impl",
"OpenSearchVectorDBStorage": ".kg.opensearch_impl",
}
def verify_storage_implementation(storage_type: str, storage_name: str) -> None:
"""Verify if storage implementation is compatible with specified storage type
Args:
storage_type: Storage type (KV_STORAGE, GRAPH_STORAGE etc.)
storage_name: Storage implementation name
Raises:
ValueError: If storage implementation is incompatible or missing required methods
"""
if storage_type not in STORAGE_IMPLEMENTATIONS:
raise ValueError(f"Unknown storage type: {storage_type}")
storage_info = STORAGE_IMPLEMENTATIONS[storage_type]
if storage_name not in storage_info["implementations"]:
raise ValueError(
f"Storage implementation '{storage_name}' is not compatible with {storage_type}. "
f"Compatible implementations are: {', '.join(storage_info['implementations'])}"
)
+344
View File
@@ -0,0 +1,344 @@
import asyncio
import os
from dataclasses import dataclass
from typing import Any, final
import numpy as np
from lightrag.base import BaseVectorStorage
from lightrag.constants import DEFAULT_QUERY_PRIORITY
from lightrag.utils import logger
import pipmaster as pm
if not pm.is_installed("chromadb"):
pm.install("chromadb")
from chromadb import HttpClient, PersistentClient # type: ignore
from chromadb.config import Settings # type: ignore
@final
@dataclass
class ChromaVectorDBStorage(BaseVectorStorage):
"""ChromaDB vector storage implementation."""
def __post_init__(self):
self._validate_embedding_func()
try:
config = self.global_config.get("vector_db_storage_cls_kwargs", {})
cosine_threshold = config.get("cosine_better_than_threshold")
if cosine_threshold is None:
raise ValueError(
"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs"
)
self.cosine_better_than_threshold = cosine_threshold
user_collection_settings = config.get("collection_settings", {})
# Default HNSW index settings for ChromaDB
default_collection_settings = {
# Distance metric used for similarity search (cosine similarity)
"hnsw:space": "cosine",
# Number of nearest neighbors to explore during index construction
# Higher values = better recall but slower indexing
"hnsw:construction_ef": 128,
# Number of nearest neighbors to explore during search
# Higher values = better recall but slower search
"hnsw:search_ef": 128,
# Number of connections per node in the HNSW graph
# Higher values = better recall but more memory usage
"hnsw:M": 16,
# Number of vectors to process in one batch during indexing
"hnsw:batch_size": 100,
# Number of updates before forcing index synchronization
# Lower values = more frequent syncs but slower indexing
"hnsw:sync_threshold": 1000,
}
collection_settings = {
**default_collection_settings,
**user_collection_settings,
}
local_path = config.get("local_path", None)
if local_path:
self._client = PersistentClient(
path=local_path,
settings=Settings(
allow_reset=True,
anonymized_telemetry=False,
),
)
else:
auth_provider = config.get(
"auth_provider", "chromadb.auth.token_authn.TokenAuthClientProvider"
)
auth_credentials = config.get("auth_token", "secret-token")
headers = {}
if "token_authn" in auth_provider:
headers = {
config.get(
"auth_header_name", "X-Chroma-Token"
): auth_credentials
}
elif "basic_authn" in auth_provider:
auth_credentials = config.get("auth_credentials", "admin:admin")
self._client = HttpClient(
host=config.get("host", "localhost"),
port=config.get("port", 8000),
headers=headers,
settings=Settings(
chroma_api_impl="rest",
chroma_client_auth_provider=auth_provider,
chroma_client_auth_credentials=auth_credentials,
allow_reset=True,
anonymized_telemetry=False,
),
)
self._collection = self._client.get_or_create_collection(
name=self.namespace,
metadata={
**collection_settings,
"dimension": self.embedding_func.embedding_dim,
},
)
# Use batch size from collection settings if specified
self._max_batch_size = self.global_config.get(
"embedding_batch_num", collection_settings.get("hnsw:batch_size", 32)
)
except Exception as e:
logger.error(f"ChromaDB initialization failed: {str(e)}")
raise
async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
logger.debug(f"Inserting {len(data)} to {self.namespace}")
if not data:
return
try:
import time
current_time = int(time.time())
ids = list(data.keys())
documents = [v["content"] for v in data.values()]
metadatas = [
{
**{k: v for k, v in item.items() if k in self.meta_fields},
"created_at": current_time,
}
or {"_default": "true", "created_at": current_time}
for item in data.values()
]
# Process in batches
batches = [
documents[i : i + self._max_batch_size]
for i in range(0, len(documents), self._max_batch_size)
]
embedding_tasks = [self.embedding_func(batch) for batch in batches]
embeddings_list = []
# Pre-allocate embeddings_list with known size
embeddings_list = [None] * len(embedding_tasks)
# Use asyncio.gather instead of as_completed if order doesn't matter
embeddings_results = await asyncio.gather(*embedding_tasks)
embeddings_list = list(embeddings_results)
embeddings = np.concatenate(embeddings_list)
# Upsert in batches
for i in range(0, len(ids), self._max_batch_size):
batch_slice = slice(i, i + self._max_batch_size)
self._collection.upsert(
ids=ids[batch_slice],
embeddings=embeddings[batch_slice].tolist(),
documents=documents[batch_slice],
metadatas=metadatas[batch_slice],
)
return ids
except Exception as e:
logger.error(f"Error during ChromaDB upsert: {str(e)}")
raise
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]:
try:
embedding = await self.embedding_func(
[query], _priority=DEFAULT_QUERY_PRIORITY
) # higher priority for query
results = self._collection.query(
query_embeddings=embedding.tolist()
if not isinstance(embedding, list)
else embedding,
n_results=top_k * 2, # Request more results to allow for filtering
include=["metadatas", "distances", "documents"],
)
# Filter results by cosine similarity threshold and take top k
# We request 2x results initially to have enough after filtering
# ChromaDB returns cosine similarity (1 = identical, 0 = orthogonal)
# We convert to distance (0 = identical, 1 = orthogonal) via (1 - similarity)
# Only keep results with distance below threshold, then take top k
return [
{
"id": results["ids"][0][i],
"distance": 1 - results["distances"][0][i],
"content": results["documents"][0][i],
"created_at": results["metadatas"][0][i].get("created_at"),
**results["metadatas"][0][i],
}
for i in range(len(results["ids"][0]))
if (1 - results["distances"][0][i]) >= self.cosine_better_than_threshold
][:top_k]
except Exception as e:
logger.error(f"Error during ChromaDB query: {str(e)}")
raise
async def index_done_callback(self) -> None:
# ChromaDB handles persistence automatically
pass
async def delete_entity(self, entity_name: str) -> None:
"""Delete an entity by its ID.
Args:
entity_name: The ID of the entity to delete
"""
try:
logger.info(f"Deleting entity with ID {entity_name} from {self.namespace}")
self._collection.delete(ids=[entity_name])
except Exception as e:
logger.error(f"Error during entity deletion: {str(e)}")
raise
async def delete_entity_relation(self, entity_name: str) -> None:
"""Delete an entity and its relations by ID.
In vector DB context, this is equivalent to delete_entity.
Args:
entity_name: The ID of the entity to delete
"""
await self.delete_entity(entity_name)
async def delete(self, ids: list[str]) -> None:
"""Delete vectors with specified IDs
Args:
ids: List of vector IDs to be deleted
"""
try:
self._collection.delete(ids=ids)
logger.debug(
f"Successfully deleted {len(ids)} vectors from {self.namespace}"
)
except Exception as e:
logger.error(f"Error while deleting vectors from {self.namespace}: {e}")
raise
except Exception as e:
logger.error(f"Error during prefix search in ChromaDB: {str(e)}")
raise
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Query the collection for a single vector by ID
result = self._collection.get(
ids=[id], include=["metadatas", "embeddings", "documents"]
)
if not result or not result["ids"] or len(result["ids"]) == 0:
return None
# Format the result to match the expected structure
return {
"id": result["ids"][0],
"vector": result["embeddings"][0],
"content": result["documents"][0],
"created_at": result["metadatas"][0].get("created_at"),
**result["metadatas"][0],
}
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Query the collection for multiple vectors by IDs
result = self._collection.get(
ids=ids, include=["metadatas", "embeddings", "documents"]
)
if not result or not result["ids"] or len(result["ids"]) == 0:
return []
# Format the results to match the expected structure and preserve ordering
formatted_map: dict[str, dict[str, Any]] = {}
for i, result_id in enumerate(result["ids"]):
record = {
"id": result_id,
"vector": result["embeddings"][i],
"content": result["documents"][i],
"created_at": result["metadatas"][i].get("created_at"),
**result["metadatas"][i],
}
formatted_map[str(result_id)] = record
ordered_results: list[dict[str, Any] | None] = []
for requested_id in ids:
ordered_results.append(formatted_map.get(str(requested_id)))
return ordered_results
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
async def drop(self) -> dict[str, str]:
"""Drop all vector data from storage and clean up resources
This method will delete all documents from the ChromaDB collection.
Returns:
dict[str, str]: Operation status and message
- On success: {"status": "success", "message": "data dropped"}
- On failure: {"status": "error", "message": "<error details>"}
"""
try:
# Get all IDs in the collection
result = self._collection.get(include=[])
if result and result["ids"] and len(result["ids"]) > 0:
# Delete all documents
self._collection.delete(ids=result["ids"])
logger.info(
f"Process {os.getpid()} drop ChromaDB collection {self.namespace}"
)
return {"status": "success", "message": "data dropped"}
except Exception as e:
logger.error(f"Error dropping ChromaDB collection {self.namespace}: {e}")
return {"status": "error", "message": str(e)}
+42
View File
@@ -0,0 +1,42 @@
"""Storage backend class factory.
Resolves a storage backend name (e.g. ``"JsonKVStorage"``) to its concrete
implementation class. The four default backends are imported directly so
they always work without depending on the ``STORAGES`` registry; everything
else is resolved lazily through the registry.
"""
from __future__ import annotations
import importlib
from typing import Any, Callable
from lightrag.kg import STORAGES
def get_storage_class(storage_name: str) -> Callable[..., Any]:
"""Return the storage backend class for ``storage_name``."""
if storage_name == "JsonKVStorage":
from lightrag.kg.json_kv_impl import JsonKVStorage
return JsonKVStorage
if storage_name == "NanoVectorDBStorage":
from lightrag.kg.nano_vector_db_impl import NanoVectorDBStorage
return NanoVectorDBStorage
if storage_name == "NetworkXStorage":
from lightrag.kg.networkx_impl import NetworkXStorage
return NetworkXStorage
if storage_name == "JsonDocStatusStorage":
from lightrag.kg.json_doc_status_impl import JsonDocStatusStorage
return JsonDocStatusStorage
# Fallback to dynamic import for other storage implementations.
# STORAGES values are relative paths (e.g. ".kg.postgres_impl") authored
# against the top-level ``lightrag`` package, so anchor the import there
# rather than letting it resolve against this module's own package.
import_path = STORAGES[storage_name]
module = importlib.import_module(import_path, package="lightrag")
return getattr(module, storage_name)
File diff suppressed because it is too large Load Diff
+554
View File
@@ -0,0 +1,554 @@
from dataclasses import dataclass
import os
from typing import Any, Union, final
from lightrag.base import (
DocProcessingStatus,
DocStatus,
DocStatusStorage,
)
from lightrag.file_atomic import reap_orphan_tmp_files
from lightrag.utils import (
_cooperative_yield,
load_json,
logger,
validate_workspace,
write_json,
get_pinyin_sort_key,
)
from lightrag.exceptions import StorageNotInitializedError
from .shared_storage import (
get_namespace_data,
get_namespace_lock,
get_data_init_lock,
get_update_flag,
set_all_update_flags,
clear_all_update_flags,
try_initialize_namespace,
)
@final
@dataclass
class JsonDocStatusStorage(DocStatusStorage):
"""JSON-file-backed document-status storage, sharing memory across processes.
Uses the **same shared-memory + dirty-flag protocol** as
``JsonKVStorage`` — see that class's docstring for the canonical
description of:
* how ``self._data`` is a cross-process
``multiprocessing.Manager().dict()`` proxy obtained via
``get_namespace_data``;
* how ``try_initialize_namespace`` ensures exactly one process
reads the JSON file on first init;
* how ``set_all_update_flags`` marks dirty state (semantics
*reversed* from the file-backed classes
``NanoVectorDBStorage`` / ``FaissVectorDBStorage`` /
``NetworkXStorage``);
* how ``index_done_callback`` flushes and calls
``clear_all_update_flags``;
* why ``_storage_lock`` wraps **every** ``self._data`` access
(not just commit / reload).
Differences from ``JsonKVStorage`` (in this class only):
* ``upsert`` calls ``index_done_callback`` synchronously after
mutating shared memory, so doc-status changes hit disk
immediately rather than being deferred to the pipeline's
batched ``_insert_done()``. Rationale: doc-status is the
recovery anchor for the ingest pipeline — if the process
crashes after an in-memory upsert but before the next batch
commit, the doc must still be visible as PENDING/PROCESSING
on restart. The other writes (``delete``, ``drop``) follow
the standard deferred-commit pattern.
* Pre-upsert preparation (``chunks_list`` default) runs
*outside* the lock because it only mutates the caller-
supplied dict, not the shared store.
* Read methods are richer (``get_docs_by_status`` /
``get_docs_by_track_id`` / ``get_docs_paginated`` /
``get_doc_by_file_path`` / etc.), but they all follow the
same "acquire ``_storage_lock``, scan ``self._data``, copy
values out before returning" template.
Non-pipeline write paths:
* ``drop`` — destructive, **not** serialized; the caller must
hold the pipeline ``busy`` reservation (the
``/documents/clear`` endpoint does this).
"""
def __post_init__(self):
# Reject path traversal before using workspace in a file path
validate_workspace(self.workspace)
working_dir = self.global_config["working_dir"]
if self.workspace:
# Include workspace in the file path for data isolation
workspace_dir = os.path.join(working_dir, self.workspace)
else:
# Default behavior when workspace is empty
workspace_dir = working_dir
self.workspace = ""
os.makedirs(workspace_dir, exist_ok=True)
self._file_name = os.path.join(workspace_dir, f"kv_store_{self.namespace}.json")
self._data = None
self._storage_lock = None
self.storage_updated = None
reap_orphan_tmp_files(self._file_name, self.workspace or "_")
async def initialize(self):
"""Bind to the shared namespace dict and load from disk on first init.
Same protocol as ``JsonKVStorage.initialize``: a global init
lock (``try_initialize_namespace``) elects one process to read
the JSON file into the shared ``self._data``; other processes
skip the read and see the same shared dict.
"""
self._storage_lock = get_namespace_lock(
self.namespace, workspace=self.workspace
)
self.storage_updated = await get_update_flag(
self.namespace, workspace=self.workspace
)
async with get_data_init_lock():
# check need_init must before get_namespace_data
need_init = await try_initialize_namespace(
self.namespace, workspace=self.workspace
)
self._data = await get_namespace_data(
self.namespace, workspace=self.workspace
)
if need_init:
loaded_data = load_json(self._file_name) or {}
async with self._storage_lock:
self._data.update(loaded_data)
logger.info(
f"[{self.workspace}] Process {os.getpid()} doc status load {self.namespace} with {len(loaded_data)} records"
)
async def filter_keys(self, keys: set[str]) -> set[str]:
"""Return keys that should be processed (not in storage or not successfully processed)"""
if self._storage_lock is None:
raise StorageNotInitializedError("JsonDocStatusStorage")
async with self._storage_lock:
return set(keys) - set(self._data.keys())
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
ordered_results: list[dict[str, Any] | None] = []
if self._storage_lock is None:
raise StorageNotInitializedError("JsonDocStatusStorage")
async with self._storage_lock:
for id in ids:
data = self._data.get(id, None)
if data:
ordered_results.append(data.copy())
else:
ordered_results.append(None)
return ordered_results
async def get_status_counts(self) -> dict[str, int]:
"""Get counts of documents in each status"""
counts = {status.value: 0 for status in DocStatus}
if self._storage_lock is None:
raise StorageNotInitializedError("JsonDocStatusStorage")
async with self._storage_lock:
for doc in self._data.values():
counts[doc["status"]] += 1
return counts
async def get_docs_by_status(
self, status: DocStatus
) -> dict[str, DocProcessingStatus]:
"""Get all documents with a specific status"""
return await self.get_docs_by_statuses([status])
async def get_docs_by_statuses(
self, statuses: list[DocStatus]
) -> dict[str, DocProcessingStatus]:
"""Get all documents matching any of the given statuses in a single pass.
Acquires the storage lock once and scans the in-memory dict once,
filtering against a set of status values. More efficient than N separate
get_docs_by_status() calls, which would acquire the lock N times and scan
the data N times.
"""
if not statuses:
return {}
status_values = {s.value for s in statuses}
result = {}
async with self._storage_lock:
for k, v in self._data.items():
if v["status"] not in status_values:
continue
try:
data = v.copy()
data.pop("content", None)
if not data.get("file_path"):
data["file_path"] = "no-file-path"
if "metadata" not in data:
data["metadata"] = {}
if "error_msg" not in data:
data["error_msg"] = None
result[k] = DocProcessingStatus(**data)
except (KeyError, TypeError) as e:
logger.error(
f"[{self.workspace}] Missing required field for document {k}: {e}"
)
continue
return result
async def get_docs_by_track_id(
self, track_id: str
) -> dict[str, DocProcessingStatus]:
"""Get all documents with a specific track_id"""
result = {}
async with self._storage_lock:
for k, v in self._data.items():
if v.get("track_id") == track_id:
try:
# Make a copy of the data to avoid modifying the original
data = v.copy()
# Remove deprecated content field if it exists
data.pop("content", None)
# Normalize missing or null file_path
if not data.get("file_path"):
data["file_path"] = "no-file-path"
# Ensure new fields exist with default values
if "metadata" not in data:
data["metadata"] = {}
if "error_msg" not in data:
data["error_msg"] = None
result[k] = DocProcessingStatus(**data)
except KeyError as e:
logger.error(
f"[{self.workspace}] Missing required field for document {k}: {e}"
)
continue
return result
async def index_done_callback(self) -> None:
"""Flush dirty shared memory to disk and clear all dirty flags.
Identical commit protocol to ``JsonKVStorage.index_done_callback``
(snapshot the shared dict → ``write_json`` → if sanitization
happened reload the cleaned data → ``clear_all_update_flags``).
See ``JsonKVStorage`` docstring for details.
"""
async with self._storage_lock:
if self.storage_updated.value:
data_dict = (
dict(self._data) if hasattr(self._data, "_getvalue") else self._data
)
logger.debug(
f"[{self.workspace}] Process {os.getpid()} doc status writting {len(data_dict)} records to {self.namespace}"
)
# Write JSON and check if sanitization was applied
needs_reload = write_json(data_dict, self._file_name)
# If data was sanitized, reload cleaned data to update shared memory
if needs_reload:
logger.info(
f"[{self.workspace}] Reloading sanitized data into shared memory for {self.namespace}"
)
cleaned_data = load_json(self._file_name)
if cleaned_data is not None:
self._data.clear()
self._data.update(cleaned_data)
await clear_all_update_flags(self.namespace, workspace=self.workspace)
async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
"""Insert/update doc-status records and **persist immediately**.
Differs from ``JsonKVStorage.upsert`` in that it calls
``index_done_callback`` synchronously at the end, so changes
are flushed to disk before this coroutine returns. Rationale:
doc-status is the recovery anchor for the ingest pipeline — if
the process crashes after an in-memory upsert but before the
next batch commit, the doc must still be visible as
PENDING/PROCESSING on restart.
Steps:
1. Pre-process the caller's dict (default ``chunks_list``)
**outside** the lock — only mutates the caller-supplied
value dicts, not shared state.
2. Under ``_storage_lock``, ``self._data.update(data)`` and
``set_all_update_flags`` to mark every process dirty.
3. Await ``index_done_callback`` for an immediate flush.
See ``JsonKVStorage`` class docstring for the shared-memory +
dirty-flag protocol that underpins step 2.
"""
if not data:
return
logger.debug(
f"[{self.workspace}] Inserting {len(data)} records to {self.namespace}"
)
if self._storage_lock is None:
raise StorageNotInitializedError("JsonDocStatusStorage")
# Prepare data outside the lock: this only mutates the caller-supplied
# dict values, not shared storage state, so no lock needed here.
for i, (doc_id, doc_data) in enumerate(data.items(), start=1):
if "chunks_list" not in doc_data:
doc_data["chunks_list"] = []
await _cooperative_yield(i)
async with self._storage_lock:
self._data.update(data)
await set_all_update_flags(self.namespace, workspace=self.workspace)
await self.index_done_callback()
async def is_empty(self) -> bool:
"""Check if the storage is empty
Returns:
bool: True if storage is empty, False otherwise
Raises:
StorageNotInitializedError: If storage is not initialized
"""
if self._storage_lock is None:
raise StorageNotInitializedError("JsonDocStatusStorage")
async with self._storage_lock:
return len(self._data) == 0
async def get_by_id(self, id: str) -> Union[dict[str, Any], None]:
async with self._storage_lock:
return self._data.get(id)
async def get_docs_paginated(
self,
status_filter: DocStatus | None = None,
status_filters: list[DocStatus] | None = None,
page: int = 1,
page_size: int = 50,
sort_field: str = "updated_at",
sort_direction: str = "desc",
) -> tuple[list[tuple[str, DocProcessingStatus]], int]:
"""Get documents with pagination support
Args:
status_filter: Filter by document status, None for all statuses
page: Page number (1-based)
page_size: Number of documents per page (10-200)
sort_field: Field to sort by ('created_at', 'updated_at', 'id')
sort_direction: Sort direction ('asc' or 'desc')
Returns:
Tuple of (list of (doc_id, DocProcessingStatus) tuples, total_count)
"""
status_filter_values = self.resolve_status_filter_values(
status_filter=status_filter,
status_filters=status_filters,
)
# Validate parameters
if page < 1:
page = 1
if page_size < 10:
page_size = 10
elif page_size > 200:
page_size = 200
if sort_field not in ["created_at", "updated_at", "id", "file_path"]:
sort_field = "updated_at"
if sort_direction.lower() not in ["asc", "desc"]:
sort_direction = "desc"
# For JSON storage, we load all data and sort/filter in memory
all_docs = []
async with self._storage_lock:
for doc_id, doc_data in self._data.items():
# Apply status filter
if (
status_filter_values is not None
and doc_data.get("status") not in status_filter_values
):
continue
try:
# Prepare document data
data = doc_data.copy()
data.pop("content", None)
if not data.get("file_path"):
data["file_path"] = "no-file-path"
if "metadata" not in data:
data["metadata"] = {}
if "error_msg" not in data:
data["error_msg"] = None
doc_status = DocProcessingStatus(**data)
# Add sort key for sorting
if sort_field == "id":
doc_status._sort_key = doc_id
elif sort_field == "file_path":
# Use pinyin sorting for file_path field to support Chinese characters
file_path_value = getattr(doc_status, sort_field, "")
doc_status._sort_key = get_pinyin_sort_key(file_path_value)
else:
doc_status._sort_key = getattr(doc_status, sort_field, "")
all_docs.append((doc_id, doc_status))
except KeyError as e:
logger.error(
f"[{self.workspace}] Error processing document {doc_id}: {e}"
)
continue
# Sort documents
reverse_sort = sort_direction.lower() == "desc"
all_docs.sort(
key=lambda x: getattr(x[1], "_sort_key", ""), reverse=reverse_sort
)
# Remove sort key from documents
for doc_id, doc in all_docs:
if hasattr(doc, "_sort_key"):
delattr(doc, "_sort_key")
total_count = len(all_docs)
# Apply pagination
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_docs = all_docs[start_idx:end_idx]
return paginated_docs, total_count
async def get_all_status_counts(self) -> dict[str, int]:
"""Get counts of documents in each status for all documents
Returns:
Dictionary mapping status names to counts, including 'all' field
"""
counts = await self.get_status_counts()
# Add 'all' field with total count
total_count = sum(counts.values())
counts["all"] = total_count
return counts
async def delete(self, doc_ids: list[str]) -> None:
"""Remove doc-status records from shared memory.
Unlike ``upsert``, ``delete`` does **not** force an immediate
flush — it follows the standard deferred-commit pattern.
Persistence happens at the next ``index_done_callback``
(driven by the pipeline's ``_insert_done()`` at end of batch).
Only calls ``set_all_update_flags`` if at least one key was
actually present (avoids creating spurious dirty state for
no-op deletes).
Args:
doc_ids: List of document IDs to be deleted from storage
"""
async with self._storage_lock:
any_deleted = False
for doc_id in doc_ids:
result = self._data.pop(doc_id, None)
if result is not None:
any_deleted = True
if any_deleted:
await set_all_update_flags(self.namespace, workspace=self.workspace)
async def get_doc_by_file_path(self, file_path: str) -> Union[dict[str, Any], None]:
"""Get document by file path
Args:
file_path: The file path to search for
Returns:
Union[dict[str, Any], None]: Document data if found, None otherwise
Returns the same format as get_by_ids method
"""
if self._storage_lock is None:
raise StorageNotInitializedError("JsonDocStatusStorage")
async with self._storage_lock:
for doc_id, doc_data in self._data.items():
if doc_data.get("file_path") == file_path:
# Return complete document data, consistent with get_by_ids method
return doc_data
return None
async def get_doc_by_file_basename(
self, basename: str
) -> Union[tuple[str, dict[str, Any]], None]:
"""Find an existing record whose canonical basename matches.
The caller is responsible for passing an already-canonical basename.
Stored ``file_path`` values are canonicalized by the business layer, so
this lookup intentionally performs an exact match only.
"""
if not basename:
return None
if self._storage_lock is None:
raise StorageNotInitializedError("JsonDocStatusStorage")
if basename == "unknown_source":
return None
async with self._storage_lock:
for doc_id, doc_data in self._data.items():
if doc_data.get("file_path") == basename:
return doc_id, doc_data
return None
async def get_doc_by_content_hash(
self, content_hash: str
) -> Union[tuple[str, dict[str, Any]], None]:
"""Find an existing record whose content_hash field matches."""
if not content_hash:
return None
if self._storage_lock is None:
raise StorageNotInitializedError("JsonDocStatusStorage")
async with self._storage_lock:
for doc_id, doc_data in self._data.items():
if doc_data.get("content_hash") == content_hash:
return doc_id, doc_data
return None
async def drop(self) -> dict[str, str]:
"""Clear shared memory and immediately persist the empty state.
This method will:
1. Clear the shared ``self._data`` dict under
``_storage_lock`` (visible to all processes immediately).
2. ``set_all_update_flags`` so every process knows there is
dirty state pending persistence.
3. Call ``index_done_callback`` synchronously to flush the
empty state to disk and clear the dirty flags.
Caller contract:
``drop`` is destructive and **not** serialized by this
storage class. The caller must hold the pipeline ``busy``
reservation (the ``/documents/clear`` endpoint does this)
before invoking it. See class docstring,
*Non-pipeline write paths*.
Returns:
dict[str, str]: Operation status and message
- On success: {"status": "success", "message": "data dropped"}
- On failure: {"status": "error", "message": "<error details>"}
"""
try:
async with self._storage_lock:
self._data.clear()
await set_all_update_flags(self.namespace, workspace=self.workspace)
await self.index_done_callback()
logger.info(
f"[{self.workspace}] Process {os.getpid()} drop {self.namespace}"
)
return {"status": "success", "message": "data dropped"}
except Exception as e:
logger.error(f"[{self.workspace}] Error dropping {self.namespace}: {e}")
return {"status": "error", "message": str(e)}
+492
View File
@@ -0,0 +1,492 @@
import os
from dataclasses import dataclass
from typing import Any, final
from lightrag.base import (
BaseKVStorage,
)
from lightrag.file_atomic import reap_orphan_tmp_files
from lightrag.utils import (
_cooperative_yield,
load_json,
logger,
validate_workspace,
write_json,
)
from lightrag.exceptions import StorageNotInitializedError
from .shared_storage import (
get_namespace_data,
get_namespace_lock,
get_data_init_lock,
get_update_flag,
set_all_update_flags,
clear_all_update_flags,
try_initialize_namespace,
)
@final
@dataclass
class JsonKVStorage(BaseKVStorage):
"""JSON-file-backed KV storage with **shared in-memory state across processes**.
This class uses a *fundamentally different* cross-process model from
``NanoVectorDBStorage`` / ``FaissVectorDBStorage`` / ``NetworkXStorage``
(which keep one in-memory copy per process and reconcile via file
reloads). Compare carefully before changing either side.
Storage model:
``self._data`` is **not** a per-process dict — it is the value
returned by ``get_namespace_data(namespace, workspace=...)``, i.e.
a reference into ``shared_storage._shared_dicts``. In multi-
process mode this is a ``multiprocessing.Manager().dict()`` proxy
that every worker sees the **same instance** of; in single-
process mode it degrades to a plain ``dict``. Either way, a
mutation in any process is *immediately* visible to every other
process — there is no reload needed.
The on-disk file at
``working_dir/[workspace/]kv_store_<namespace>.json`` exists for
durability only. It is the source of truth at startup and the
target of ``index_done_callback`` flushes, but is **not** part of
the steady-state read/write path.
First-time load (``initialize``):
``try_initialize_namespace`` is a global init lock that returns
``True`` to exactly one process per ``(namespace, workspace)``.
That process reads the JSON file and populates ``self._data``
under ``_storage_lock``. Other processes skip the load — they
will see the data through the same shared ``self._data`` proxy.
Cross-process sync protocol (note: reversed semantics vs file-backed
classes):
Anyone writing (``upsert`` / ``delete`` / ``drop``):
1. Mutate ``self._data`` under ``_storage_lock`` (same lock,
same dict, all processes see the change immediately).
2. Call ``set_all_update_flags`` to mark **every** process's
``storage_updated`` flag ``True``. Here ``True`` means
*"there is dirty data that still needs to be flushed"*,
not *"there is fresher data on disk that I need to
reload"* as in the file-backed implementations.
Commit (``index_done_callback``):
1. Under ``_storage_lock``, if ``storage_updated.value`` is
``True``, snapshot ``self._data`` and write it to disk
via ``write_json`` (atomic).
2. ``clear_all_update_flags`` — wipe every process's flag
back to ``False``. Because the in-memory state is already
consistent across processes, there is nothing for the
*other* processes to do; the clear is just a
"the dirty data has been persisted" signal.
Lock scope:
``_storage_lock`` is a per-``(namespace, workspace)`` keyed lock
spanning intra-process coroutines **and** inter-process workers.
Unlike the file-backed classes (which only lock reload/commit
critical sections), this class **holds the lock over every
``self._data`` access** — read or write — because the underlying
``Manager().dict()`` is not free-threaded across processes.
Two places intentionally do work outside the lock for latency
reasons:
* ``upsert`` performs its per-key timestamp prep loop inside
the lock but yields to the event loop via
``_cooperative_yield`` between keys (safe: ``NamespaceLock``
is non-reentrant, so siblings blocked on it stay blocked).
* ``JsonDocStatusStorage.upsert`` prepares its caller-supplied
dict outside the lock (it only mutates the input, not the
shared store).
Who can write:
Pipeline ``busy`` still serializes the document ingest / purge
flows, but the *file-flush trigger* is symmetric: any process
whose ``storage_updated.value`` is ``True`` when
``index_done_callback`` fires will perform the write. In a
single-writer pipeline this is always the same process; if you
ever permit multiple writers, two processes may race to flush
the same in-memory state — that race is safe (both flush the
same shared dict, ``write_json`` is atomic per file) but
wasteful, and the ``clear_all_update_flags`` after each flush
means subsequent re-flushes are no-ops.
Caveats vs file-backed implementations:
* **No reload path.** If something writes to the on-disk file
out of band, this class will not pick it up until restart.
The file is only ever written by ``index_done_callback`` and
read once in ``initialize``.
* **No ``_get_*`` entry method.** Adding one would be wrong —
there's nothing to "get fresher than" since the in-memory
state is already the shared, authoritative view.
* **``write_json`` may sanitize.** If sanitization happens, the
on-disk JSON differs from what was in memory; the callback
re-reads the cleaned file back into ``self._data`` under the
same lock so the shared view stays consistent with disk.
Non-pipeline write paths:
* ``drop`` — destructive, **not** serialized by this storage
class. Currently gated by the API layer
(``/documents/clear``); any new caller must hold the pipeline
``busy`` reservation.
* ``upsert`` / ``delete`` invoked from non-pipeline admin flows
(cache management, etc.) — safe under the shared-lock model,
but consumers should still respect the pipeline gate to avoid
interleaving with batched ingest work.
"""
def __post_init__(self):
# Reject path traversal before using workspace in a file path
validate_workspace(self.workspace)
working_dir = self.global_config["working_dir"]
if self.workspace:
# Include workspace in the file path for data isolation
workspace_dir = os.path.join(working_dir, self.workspace)
else:
# Default behavior when workspace is empty
workspace_dir = working_dir
self.workspace = ""
os.makedirs(workspace_dir, exist_ok=True)
self._file_name = os.path.join(workspace_dir, f"kv_store_{self.namespace}.json")
self._data = None
self._storage_lock = None
self.storage_updated = None
reap_orphan_tmp_files(self._file_name, self.workspace or "_")
async def initialize(self):
"""Bind to the shared namespace dict and load from disk on first init.
``try_initialize_namespace`` is a global init lock that returns
``True`` for exactly one process per ``(namespace, workspace)``;
that process reads the JSON file and populates the shared
``self._data`` under ``_storage_lock``. Subsequent processes
skip the file read — they will see the same shared dict via
``get_namespace_data``.
For ``*_cache`` namespaces an extra
``_migrate_legacy_cache_structure`` pass runs against the loaded
data and may rewrite the on-disk file if a migration was applied.
"""
self._storage_lock = get_namespace_lock(
self.namespace, workspace=self.workspace
)
self.storage_updated = await get_update_flag(
self.namespace, workspace=self.workspace
)
async with get_data_init_lock():
# check need_init must before get_namespace_data
need_init = await try_initialize_namespace(
self.namespace, workspace=self.workspace
)
self._data = await get_namespace_data(
self.namespace, workspace=self.workspace
)
if need_init:
loaded_data = load_json(self._file_name) or {}
async with self._storage_lock:
# Migrate legacy cache structure if needed
if self.namespace.endswith("_cache"):
loaded_data = await self._migrate_legacy_cache_structure(
loaded_data
)
self._data.update(loaded_data)
data_count = len(loaded_data)
logger.info(
f"[{self.workspace}] Process {os.getpid()} KV load {self.namespace} with {data_count} records"
)
async def index_done_callback(self) -> None:
"""Flush dirty in-memory state to disk and clear all dirty flags.
Commit point in the shared-memory protocol (see class docstring,
*Cross-process sync protocol*). Steps:
1. Under ``_storage_lock``, check this process's
``storage_updated.value``. If ``False``, nothing to do —
return.
2. Snapshot ``self._data`` (converting from ``Manager.dict``
proxy to a plain ``dict`` so the JSON encoder doesn't trip
over the proxy) and write it via ``write_json``.
3. If ``write_json`` reports sanitization was applied, the
on-disk file no longer matches what was in memory — reload
the cleaned data back into ``self._data`` under the same
lock so the shared view stays consistent.
4. ``clear_all_update_flags`` — wipe every process's
``storage_updated`` flag back to ``False``, signaling
that the dirty data has been persisted.
Note the **semantic difference** from the file-backed classes'
commit: there is no ``set_all_update_flags`` here. The shared
dict is already consistent across processes; the only thing
``index_done_callback`` does globally is *clear* the dirty
flags.
"""
async with self._storage_lock:
if self.storage_updated.value:
data_dict = (
dict(self._data) if hasattr(self._data, "_getvalue") else self._data
)
# Calculate data count - all data is now flattened
data_count = len(data_dict)
logger.debug(
f"[{self.workspace}] Process {os.getpid()} KV writting {data_count} records to {self.namespace}"
)
# Write JSON and check if sanitization was applied
needs_reload = write_json(data_dict, self._file_name)
# If data was sanitized, reload cleaned data to update shared memory
if needs_reload:
logger.info(
f"[{self.workspace}] Reloading sanitized data into shared memory for {self.namespace}"
)
cleaned_data = load_json(self._file_name)
if cleaned_data is not None:
self._data.clear()
self._data.update(cleaned_data)
await clear_all_update_flags(self.namespace, workspace=self.workspace)
async def get_by_id(self, id: str) -> dict[str, Any] | None:
async with self._storage_lock:
result = self._data.get(id)
if result:
# Create a copy to avoid modifying the original data
result = dict(result)
# Ensure time fields are present, provide default values for old data
result.setdefault("create_time", 0)
result.setdefault("update_time", 0)
# Ensure _id field contains the clean ID
result["_id"] = id
return result
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
async with self._storage_lock:
results = []
for id in ids:
data = self._data.get(id, None)
if data:
# Create a copy to avoid modifying the original data
result = {k: v for k, v in data.items()}
# Ensure time fields are present, provide default values for old data
result.setdefault("create_time", 0)
result.setdefault("update_time", 0)
# Ensure _id field contains the clean ID
result["_id"] = id
results.append(result)
else:
results.append(None)
return results
async def filter_keys(self, keys: set[str]) -> set[str]:
async with self._storage_lock:
return set(keys) - set(self._data.keys())
async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
"""Insert or update KV records in shared memory; mark all processes dirty.
Two side effects under ``_storage_lock``:
1. Stamp ``create_time`` / ``update_time`` / ``_id`` on each
value, then ``self._data.update(data)``. Because
``self._data`` is the shared ``Manager.dict()`` proxy, the
update is visible to all processes immediately — no
reload needed.
2. ``set_all_update_flags`` — flip every process's
``storage_updated.value`` to ``True``. Here ``True``
means *"there is dirty data that still needs to be
flushed to disk"*, **not** *"there is fresher data on
disk"* as in the file-backed classes (see class docstring
for the contrast).
Persistence is deferred to the next ``index_done_callback`` (the
pipeline calls this via ``_insert_done()`` after each batch).
Note: the per-key prep loop calls ``_cooperative_yield`` inside
the lock. That is safe because ``NamespaceLock`` is non-
reentrant — siblings waiting on this lock stay blocked across
the yield; only unrelated coroutines benefit from the yield.
"""
if not data:
return
import time
current_time = int(time.time()) # Get current Unix timestamp
logger.debug(
f"[{self.workspace}] Inserting {len(data)} records to {self.namespace}"
)
if self._storage_lock is None:
raise StorageNotInitializedError("JsonKVStorage")
async with self._storage_lock:
# Add timestamps to data based on whether key exists.
# The loop reads self._data (k in self._data) so it must stay inside
# the lock. _cooperative_yield is safe here: NamespaceLock is
# non-reentrant, so other coroutines waiting on this lock will block
# until we release it; the yield only benefits unrelated coroutines.
for i, (k, v) in enumerate(data.items(), start=1):
# For text_chunks namespace, ensure llm_cache_list field exists
if self.namespace.endswith("text_chunks"):
if "llm_cache_list" not in v:
v["llm_cache_list"] = []
# Add timestamps based on whether key exists
if k in self._data: # Key exists, only update update_time
v["update_time"] = current_time
else: # New key, set both create_time and update_time
v["create_time"] = current_time
v["update_time"] = current_time
v["_id"] = k
await _cooperative_yield(i)
self._data.update(data)
await set_all_update_flags(self.namespace, workspace=self.workspace)
async def delete(self, ids: list[str]) -> None:
"""Remove records from shared memory; mark all processes dirty if any deleted.
Under ``_storage_lock``: ``self._data.pop(doc_id, None)`` for
each id. Only calls ``set_all_update_flags`` if at least one key
was actually present (avoids creating spurious dirty state for
no-op deletes).
See class docstring for the shared-memory + dirty-flag protocol
and the semantic contrast vs file-backed classes.
Args:
ids: List of document IDs to be deleted from storage
"""
async with self._storage_lock:
any_deleted = False
for doc_id in ids:
result = self._data.pop(doc_id, None)
if result is not None:
any_deleted = True
if any_deleted:
await set_all_update_flags(self.namespace, workspace=self.workspace)
async def is_empty(self) -> bool:
"""Check if the storage is empty
Returns:
bool: True if storage contains no data, False otherwise
"""
async with self._storage_lock:
return len(self._data) == 0
async def drop(self) -> dict[str, str]:
"""Clear shared memory and immediately persist the empty state.
This method will:
1. Clear the shared ``self._data`` dict under
``_storage_lock`` (visible to all processes immediately).
2. ``set_all_update_flags`` so every process knows there is
dirty state pending persistence.
3. Call ``index_done_callback`` synchronously to flush the
empty state to disk and clear the dirty flags.
Caller contract:
``drop`` is destructive and **not** serialized by this
storage class. The caller must hold the pipeline ``busy``
reservation (the ``/documents/clear`` endpoint does this)
before invoking it — running ``drop`` concurrently with an
active document pipeline will wipe out in-flight work and
silently lose data. See class docstring,
*Non-pipeline write paths*.
Returns:
dict[str, str]: Operation status and message
- On success: {"status": "success", "message": "data dropped"}
- On failure: {"status": "error", "message": "<error details>"}
"""
try:
async with self._storage_lock:
self._data.clear()
await set_all_update_flags(self.namespace, workspace=self.workspace)
await self.index_done_callback()
logger.info(
f"[{self.workspace}] Process {os.getpid()} drop {self.namespace}"
)
return {"status": "success", "message": "data dropped"}
except Exception as e:
logger.error(f"[{self.workspace}] Error dropping {self.namespace}: {e}")
return {"status": "error", "message": str(e)}
async def _migrate_legacy_cache_structure(self, data: dict) -> dict:
"""Migrate legacy nested cache structure to flattened structure
Args:
data: Original data dictionary that may contain legacy structure
Returns:
Migrated data dictionary with flattened cache keys (sanitized if needed)
"""
from lightrag.utils import generate_cache_key
# Early return if data is empty
if not data:
return data
# Check first entry to see if it's already in new format
first_key = next(iter(data.keys()))
if ":" in first_key and len(first_key.split(":")) == 3:
# Already in flattened format, return as-is
return data
migrated_data = {}
migration_count = 0
for key, value in data.items():
# Check if this is a legacy nested cache structure
if isinstance(value, dict) and all(
isinstance(v, dict) and "return" in v for v in value.values()
):
# This looks like a legacy cache mode with nested structure
mode = key
for cache_hash, cache_entry in value.items():
cache_type = cache_entry.get("cache_type", "extract")
flattened_key = generate_cache_key(mode, cache_type, cache_hash)
migrated_data[flattened_key] = cache_entry
migration_count += 1
else:
# Keep non-cache data or already flattened cache data as-is
migrated_data[key] = value
if migration_count > 0:
logger.info(
f"[{self.workspace}] Migrated {migration_count} legacy cache entries to flattened structure"
)
# Persist migrated data immediately and check if sanitization was applied
needs_reload = write_json(migrated_data, self._file_name)
# If data was sanitized during write, reload cleaned data
if needs_reload:
logger.info(
f"[{self.workspace}] Reloading sanitized migration data for {self.namespace}"
)
cleaned_data = load_json(self._file_name)
if cleaned_data is not None:
return cleaned_data # Return cleaned data to update shared memory
return migrated_data
async def finalize(self):
"""On shutdown, flush ``*_cache`` namespaces to disk.
Cache namespaces are routinely written to during query/extract
without triggering an immediate ``index_done_callback`` (caches
churn fast and the pipeline doesn't always end at a natural
commit point). This hook ensures whatever dirty cache state is
in shared memory at process exit gets persisted, so the next
run can pick it up.
Non-cache namespaces don't need this — their writes already
flow through pipeline-driven ``_insert_done()`` commits.
"""
if self.namespace.endswith("_cache"):
await self.index_done_callback()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+918
View File
@@ -0,0 +1,918 @@
import asyncio
import base64
import os
import zlib
from typing import Any, final
from dataclasses import dataclass
import numpy as np
import time
from lightrag.file_atomic import atomic_write, reap_orphan_tmp_files
from lightrag.utils import (
logger,
compute_mdhash_id,
validate_workspace,
)
from lightrag.base import BaseVectorStorage
from lightrag.constants import DEFAULT_QUERY_PRIORITY
from nano_vectordb import NanoVectorDB
from .shared_storage import (
get_namespace_lock,
get_update_flag,
set_all_update_flags,
)
@dataclass
class _PendingNanoDoc:
"""A buffered upsert waiting for deferred embedding and materialization.
``record`` holds ``__id__`` / ``__created_at__`` plus the ``meta_fields``
(which always include ``content`` for the entity/relation/chunk vdbs), so
the content needed for deferred embedding lives in the record itself — no
separate copy is kept. ``vector`` starts as ``None`` and is filled either
during the lock-held flush or by a lazy ``get_vectors_by_ids`` embedding;
once set it is reused by the next flush instead of re-calling the model.
The compressed ``vector`` / raw ``__vector__`` keys are added to ``record``
only at flush time, right before ``client.upsert``.
"""
record: dict[str, Any]
vector: np.ndarray | None = None
@final
@dataclass
class NanoVectorDBStorage(BaseVectorStorage):
"""File-backed vector storage built on the in-memory ``NanoVectorDB``.
Storage model:
A single ``NanoVectorDB`` instance lives in process memory; its full
state is serialized to one JSON file at
``working_dir/[workspace/]vdb_<namespace>.json``. That JSON file is
the **only** cross-process synchronization surface — there is no
shared memory, no message bus, and no network channel between
processes. All cross-process visibility is therefore mediated by
(a) an atomic file write at commit time and (b) a per-namespace
``storage_updated`` flag distributed through
``lightrag.kg.shared_storage``.
Concurrency invariants (the code in this file is correct *only* while
all three hold):
1. **Single writer per workspace.** The document pipeline's
``busy`` / ``destructive_busy`` flags (see ``AGENTS.md``
*Pipeline concurrency contract*) guarantee that at most one
process performs ``upsert`` / ``delete`` /
``index_done_callback`` at any time. Every other process is
read-only with respect to this storage.
2. **Eventual consistency is sufficient.** Read-only processes
only need to observe the writer's data *after* the writer's
``index_done_callback`` completes. Reads that land in the gap
between a writer's in-memory mutation and its commit may
legitimately return the pre-update snapshot.
3. **NanoVectorDB operations are fully synchronous.** Under a
single-threaded asyncio event loop, ``client.upsert`` /
``client.query`` / ``client.delete`` cannot be preempted by
another coroutine, which gives them implicit mutual exclusion
over ``self._client.__storage``. This is why the methods below
don't have to hold ``_storage_lock`` while calling into
``client``.
Cross-process sync protocol:
Writer side (``index_done_callback``):
1. Atomically write the in-memory state to disk
(``atomic_write`` swaps a tmp file into place).
2. Call ``set_all_update_flags`` to flip every process's
``storage_updated`` flag (including the writer's own).
3. Immediately reset the writer's own flag to ``False`` so
the next call to ``_get_client`` does not trigger a
self-reload of the data this process just wrote.
Reader side (any method that goes through ``_get_client``):
1. Inside ``_storage_lock``, observe
``storage_updated.value is True``.
2. **Fully reload** ``self._client`` from disk — NanoVectorDB
has no incremental sync API, so the entire JSON file is
re-parsed and a fresh in-memory matrix is rebuilt.
3. Reset the reader's own flag to ``False`` so concurrent
coroutines in the same process don't double-reload.
Lock scope:
``_storage_lock`` is a per-``(namespace, workspace)`` keyed lock
spanning both intra-process coroutines and inter-process workers.
It only wraps the *reload* and *commit* critical sections, not
every ``client.xxx`` call. Operating on ``client`` outside the
lock is safe today *because of invariant (3)* — if either premise
is ever broken (e.g. ``client.xxx`` is moved to a thread pool, or
NanoVectorDB is swapped for an async vector library), the lock
scope must be widened to cover the mutation/read itself.
Non-pipeline write paths:
The pipeline's ``busy`` gate serializes ``upsert`` / ``delete`` /
``index_done_callback`` called from the document ingestion and
purge flows. The following entry points are **not** serialized by
the pipeline gate and must be guarded externally:
* ``drop`` — currently gated by the API layer (the
``/documents/clear`` endpoint takes the pipeline busy
reservation before invoking it).
* ``delete_entity`` / ``delete_entity_relation`` — currently
not exposed in the WebUI. If you wire them up to a new
caller, that caller must arrange single-writer
serialization the same way the pipeline does.
Deferred-embedding protocol:
``upsert`` does **not** call the embedding model. It only buffers a
``_PendingNanoDoc`` (content-bearing record + ``vector=None``) in the
minimal ``self._pending_upserts`` area, overwriting any prior pending
doc for the same id (which also clears a temp vector a previous
``get_vectors_by_ids`` may have cached). The model is called once per
id at flush time (``_flush_pending_locked``), so repeated upserts of
the same id — and many small upsert calls — embed only once. See
issue #2785 and the ``OpenSearchVectorDBStorage`` equivalent.
Embedding runs **inside ``_storage_lock``** during the flush (not in
``upsert``): under the single-writer invariant this keeps the content
used for embedding consistent with the record written to disk and
prevents a destructive op from interleaving between embed and write.
The lock is non-reentrant, so ``_flush_pending_locked`` requires the
caller to already hold it and operates on ``self._client`` directly
(never through ``_get_client``).
Reads are read-your-writes: ``get_by_id`` / ``get_by_ids`` /
``get_vectors_by_ids`` consult ``_pending_upserts`` first.
``get_vectors_by_ids`` lazily embeds a pending doc on demand and
caches the vector back for the next flush. ``query`` and
``client_storage`` see only data already materialized into
``self._client`` — unflushed pending data is intentionally not
queryable. A flush failure (embedding error, count mismatch, or save
IO error) raises through ``index_done_callback``; the pending buffer
is preserved, and if only the save failed ``_client_dirty`` stays
``True`` so a subsequent ``finalize`` retries the save.
"""
def __post_init__(self):
# Reject path traversal before using workspace in a file path
validate_workspace(self.workspace)
self._validate_embedding_func()
# Initialize basic attributes
self._client = None
self._storage_lock = None
self.storage_updated = None
# Use global config value if specified, otherwise use default
kwargs = self.global_config.get("vector_db_storage_cls_kwargs", {})
cosine_threshold = kwargs.get("cosine_better_than_threshold")
if cosine_threshold is None:
raise ValueError(
"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs"
)
self.cosine_better_than_threshold = cosine_threshold
working_dir = self.global_config["working_dir"]
if self.workspace:
# Include workspace in the file path for data isolation
workspace_dir = os.path.join(working_dir, self.workspace)
self.final_namespace = f"{self.workspace}_{self.namespace}"
else:
# Default behavior when workspace is empty
self.final_namespace = self.namespace
self.workspace = ""
workspace_dir = working_dir
os.makedirs(workspace_dir, exist_ok=True)
self._client_file_name = os.path.join(
workspace_dir, f"vdb_{self.namespace}.json"
)
self._max_batch_size = self.global_config["embedding_batch_num"]
# Sweep orphan tmp siblings left behind by hard kills mid-save before
# NanoVectorDB opens the target file.
reap_orphan_tmp_files(self._client_file_name, self.workspace or "_")
self._client = NanoVectorDB(
self.embedding_func.embedding_dim,
storage_file=self._client_file_name,
)
# Minimal pending area for deferred embedding: id -> _PendingNanoDoc.
# Holds only records not yet embedded+materialized into self._client;
# it never duplicates rows already written to the client. Flushed
# under _storage_lock by _flush_pending_locked().
self._pending_upserts: dict[str, _PendingNanoDoc] = {}
# True when self._client has materialized changes that have not been
# successfully saved to disk yet. This lets finalize retry a save even
# after a previous flush cleared the pending buffer.
self._client_dirty = False
async def initialize(self):
"""Initialize storage data"""
# Get the update flag for cross-process update notification
self.storage_updated = await get_update_flag(
self.namespace, workspace=self.workspace
)
# Get the storage lock for use in other methods
self._storage_lock = get_namespace_lock(
self.namespace, workspace=self.workspace
)
def _reload_client_from_disk_locked(self, *, for_write: bool = False) -> bool:
"""Reload ``self._client`` if another process committed newer data.
Precondition: the caller must already hold ``_storage_lock``. This is
used by write paths as well as reads because deferred upserts mean a
stale writer must merge its pending buffer into the latest on-disk
snapshot, not save over it or return without flushing.
"""
if not self.storage_updated.value:
return False
log_message = (
f"[{self.workspace}] Process {os.getpid()} reloading {self.namespace} "
"due to update by another process"
)
if for_write:
logger.warning(log_message)
else:
logger.info(log_message)
self._client = NanoVectorDB(
self.embedding_func.embedding_dim,
storage_file=self._client_file_name,
)
self.storage_updated.value = False
return True
async def _get_client(self):
"""Return the live ``NanoVectorDB`` instance, reloading from disk if needed.
This is the **single entry point** every public method funnels
through to obtain ``self._client``. It is also the **only place
readers transition to a fresher on-disk snapshot**: when another
process has committed (via ``index_done_callback``) and flipped
this process's ``storage_updated`` flag, the next call here
rebuilds ``self._client`` by re-parsing the entire JSON file.
NanoVectorDB has no incremental sync API — the reload is
unconditionally a full file reload.
Under the *Single writer* invariant (see class docstring), the
reload branch never fires in the writer process: the writer
resets its own flag at the end of every ``index_done_callback``.
The branch exists for readers.
``_storage_lock`` is held during the check-and-reload to (a)
serialize concurrent reload attempts by sibling coroutines in
the same process and (b) interlock with ``index_done_callback``
so a reader cannot observe a partially-saved file.
"""
async with self._storage_lock:
self._reload_client_from_disk_locked()
return self._client
async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
"""Buffer vectors for deferred embedding; persistence is deferred too.
Embedding is **not** performed here. Each record is buffered in
``self._pending_upserts`` with ``vector=None`` and the embedding model
is called once per id at flush time (``_flush_pending_locked`` during
``index_done_callback`` / ``finalize``). This coalesces repeated
upserts of the same id and many small upsert calls into a single
embedding pass (see class docstring, *Deferred-embedding protocol*,
and issue #2785).
Persistence:
Changes live only in this process's memory until the next
``index_done_callback``. Cross-process readers will not see
them until that commit fires (see class docstring,
*Cross-process sync protocol*). Until the flush, an upserted id
is observable only through the read-your-writes read paths, not
through ``query``.
"""
# logger.debug(f"[{self.workspace}] Buffering {len(data)} to {self.namespace}")
if not data:
return
current_time = int(time.time())
pending = [
(
k,
{
"__id__": k,
"__created_at__": current_time,
**{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},
},
)
for k, v in data.items()
]
# Buffer under the lock to interlock with the lock-held flush. A new
# _PendingNanoDoc(vector=None) overwrites any prior pending doc for the
# same id, discarding a temp vector a previous get_vectors_by_ids may
# have cached (content-version change -> must re-embed new content).
async with self._storage_lock:
for doc_id, record in pending:
self._pending_upserts[doc_id] = _PendingNanoDoc(record=record)
async def _flush_pending_locked(self) -> None:
"""Embed pending docs and materialize them into ``self._client``.
Precondition: the caller **must already hold** ``_storage_lock``. The
lock is non-reentrant, so this helper never calls ``_get_client`` and
operates on ``self._client`` directly. Embedding runs inside the lock
on purpose (see class docstring, *Deferred-embedding protocol*).
Failure handling: if embedding raises or the returned count does not
match, the exception propagates and ``_pending_upserts`` is left intact
so the next flush retries; nothing is written to ``self._client``.
"""
if not self._pending_upserts:
return
# Snapshot for stable ordering between the embed list and the write.
pending_items = list(self._pending_upserts.items())
to_embed = [
(doc_id, pdoc) for doc_id, pdoc in pending_items if pdoc.vector is None
]
if to_embed:
contents = [pdoc.record["content"] for _, pdoc in to_embed]
batches = [
contents[i : i + self._max_batch_size]
for i in range(0, len(contents), self._max_batch_size)
]
logger.info(
f"[{self.workspace}] {self.namespace} flush: embedding "
f"{len(to_embed)} vectors in {len(batches)} batch(es) "
f"(batch_num={self._max_batch_size})"
)
try:
embeddings_list = await asyncio.gather(
*[
self.embedding_func(batch, context="document")
for batch in batches
]
)
except Exception as e:
logger.error(
f"[{self.workspace}] Error embedding pending vector ops "
f"(upserts={len(to_embed)}): {e}"
)
raise
embeddings = np.concatenate(embeddings_list)
if len(embeddings) != len(to_embed):
# Explicit raise (not a log): a mismatch would mis-pair vectors
# with records. Keep pending intact so the next flush retries.
raise RuntimeError(
f"[{self.workspace}] embedding is not 1-1 with pending data, "
f"{len(embeddings)} != {len(to_embed)}"
)
for (_, pdoc), embedding in zip(to_embed, embeddings):
pdoc.vector = embedding
list_data = []
for _, pdoc in pending_items:
vector = pdoc.vector
# Compress vector using Float16 + zlib + Base64 for storage optimization
vector_f16 = vector.astype(np.float16)
compressed_vector = zlib.compress(vector_f16.tobytes())
encoded_vector = base64.b64encode(compressed_vector).decode("utf-8")
record = pdoc.record
record["vector"] = encoded_vector
record["__vector__"] = vector
list_data.append(record)
self._client.upsert(datas=list_data)
self._client_dirty = True
# Clear only the entries we just flushed (an upsert that arrived after
# the snapshot would have re-set vector=None and must not be dropped).
for doc_id, pdoc in pending_items:
if self._pending_upserts.get(doc_id) is pdoc:
del self._pending_upserts[doc_id]
def _save_to_disk_locked(self) -> None:
"""Atomically persist ``self._client`` and notify other processes.
Precondition: the caller must already hold ``_storage_lock``. Factored
out of ``index_done_callback`` so ``finalize`` reuses the exact same
save+notify sequence. ``NanoVectorDB.save()`` always writes to whatever
path is on the instance, so we temporarily redirect ``storage_file`` to
the per-writer tmp and let ``atomic_write`` own the rename; the original
path is restored on every path (success and exception).
"""
def _save_atomic(tmp: str) -> None:
original = self._client.storage_file
self._client.storage_file = tmp
try:
self._client.save()
finally:
self._client.storage_file = original
atomic_write(self._client_file_name, _save_atomic, self.workspace or "_")
async def query(
self, query: str, top_k: int, query_embedding: list[float] = None
) -> list[dict[str, Any]]:
"""Similarity search over data already materialized into ``self._client``.
Buffered (unflushed) upserts are **not** searchable — only rows that a
prior ``index_done_callback`` / ``finalize`` flushed are considered.
Use the read-your-writes paths (``get_by_id`` / ``get_by_ids`` /
``get_vectors_by_ids``) to observe pending data before a flush.
"""
# Use provided embedding or compute it
if query_embedding is not None:
embedding = query_embedding
else:
# Execute embedding outside of lock to avoid improve cocurrent
embedding = await self.embedding_func(
[query], context="query", _priority=DEFAULT_QUERY_PRIORITY
) # higher priority for query
embedding = embedding[0]
client = await self._get_client()
results = client.query(
query=embedding,
top_k=top_k,
better_than_threshold=self.cosine_better_than_threshold,
)
results = [
{
**{k: v for k, v in dp.items() if k != "vector"},
"id": dp["__id__"],
"distance": dp["__metrics__"],
"created_at": dp.get("__created_at__"),
}
for dp in results
]
return results
@property
async def client_storage(self):
"""Return a **live reference** to ``NanoVectorDB.__storage``.
The returned dict is the same object NanoVectorDB mutates in
place during ``upsert`` / ``delete``. Reading it outside
``_storage_lock`` is safe today only because NanoVectorDB
mutations are fully synchronous (see class docstring,
*Lock scope*). Callers must not retain this reference across an
``await`` that might cross into ``_get_client`` again: a reload
will swap ``self._client`` for a fresh instance and leave the
held reference pointing at the old (now-stale) storage.
"""
client = await self._get_client()
return getattr(client, "_NanoVectorDB__storage")
async def delete(self, ids: list[str]):
"""Delete vectors with specified IDs.
Persistence:
Changes are in-memory only; cross-process visibility requires a
subsequent ``index_done_callback``. In ``lightrag.py`` this is
handled by ``_insert_done()`` at the end of the document batch.
Callers outside the pipeline must persist explicitly.
Args:
ids: List of vector IDs to be deleted
"""
try:
# Hold the lock so the pending-cancel and the client delete are a
# single critical section against a concurrent flush. Operate on
# self._client directly (the lock is non-reentrant; no _get_client).
async with self._storage_lock:
self._reload_client_from_disk_locked(for_write=True)
for doc_id in ids:
self._pending_upserts.pop(doc_id, None)
# Record count before deletion
before_count = len(self._client)
self._client.delete(ids)
# Calculate actual deleted count
after_count = len(self._client)
deleted_count = before_count - after_count
if deleted_count:
self._client_dirty = True
logger.debug(
f"[{self.workspace}] Successfully deleted {deleted_count} vectors from {self.namespace}"
)
except Exception as e:
logger.error(
f"[{self.workspace}] Error while deleting vectors from {self.namespace}: {e}"
)
async def delete_entity(self, entity_name: str) -> None:
"""Delete the vector associated with a single entity name.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. Callers outside the
pipeline must persist explicitly.
Buffer semantics — post-prune with caller short-circuit contract:
The materialized client delete runs first; the matching
pending upsert (if any) is popped **only after** it
succeeds. If the materialized delete raises, the pending
buffer stays intact and the exception is re-raised so the
caller can short-circuit before ``index_done_callback``
flushes a half-cleaned buffer.
**Not pipeline-gated** — see class docstring
*Non-pipeline write paths*. The caller is responsible for
ensuring single-writer serialization.
"""
try:
entity_id = compute_mdhash_id(entity_name, prefix="ent-")
logger.debug(
f"[{self.workspace}] Attempting to delete entity {entity_name} with ID {entity_id}"
)
async with self._storage_lock:
self._reload_client_from_disk_locked(for_write=True)
# Materialized side first so a failure leaves the
# pending buffer intact for the caller's retry path.
if self._client.get([entity_id]):
self._client.delete([entity_id])
self._client_dirty = True
deleted = True
else:
deleted = False
# Materialized delete succeeded — safe to cancel any
# buffered upsert for this entity.
pending_cancelled = (
self._pending_upserts.pop(entity_id, None) is not None
)
if deleted or pending_cancelled:
logger.debug(
f"[{self.workspace}] Successfully deleted entity {entity_name}"
)
else:
logger.debug(
f"[{self.workspace}] Entity {entity_name} not found in storage"
)
except Exception as e:
logger.error(f"[{self.workspace}] Error deleting entity {entity_name}: {e}")
raise
async def delete_entity_relation(self, entity_name: str) -> None:
"""Delete every relation vector incident to ``entity_name``.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. Callers outside the
pipeline must persist explicitly.
Buffer semantics — post-prune with caller short-circuit contract:
The materialized client delete runs first; matching pending
upserts are pruned **only after** it succeeds. If the
materialized delete raises, the pending buffer stays intact
and the exception is re-raised so the caller (e.g.
``adelete_by_entity``) can short-circuit before
``_persist_graph_updates`` triggers ``index_done_callback``
on a half-cleaned buffer.
Previously the buffer was pre-pruned and the outer
``except`` swallowed exceptions into ``logger.error`` — that
combination silently dropped both buffered relation vectors
and the failure signal.
**Not pipeline-gated** — see class docstring
*Non-pipeline write paths*. The caller is responsible for
ensuring single-writer serialization.
"""
try:
async with self._storage_lock:
self._reload_client_from_disk_locked(for_write=True)
# Materialized side first so a failure leaves the
# pending buffer intact for the caller's retry path.
# Use .get() for src_id / tgt_id so rows from foreign
# namespaces without those keys silently don't match.
storage = getattr(self._client, "_NanoVectorDB__storage")
ids_to_delete = [
dp["__id__"]
for dp in storage["data"]
if dp.get("src_id") == entity_name
or dp.get("tgt_id") == entity_name
]
if ids_to_delete:
self._client.delete(ids_to_delete)
self._client_dirty = True
# Materialized delete succeeded — safe to prune matching
# buffered upserts so a subsequent flush won't re-upsert
# the just-deleted relations.
pending_ids = [
doc_id
for doc_id, pdoc in self._pending_upserts.items()
if pdoc.record.get("src_id") == entity_name
or pdoc.record.get("tgt_id") == entity_name
]
for doc_id in pending_ids:
del self._pending_upserts[doc_id]
total = len(pending_ids) + len(ids_to_delete)
if total:
logger.debug(
f"[{self.workspace}] Deleted {total} relations for {entity_name}"
)
else:
logger.debug(
f"[{self.workspace}] No relations found for entity {entity_name}"
)
except Exception as e:
logger.error(
f"[{self.workspace}] Error deleting relations for {entity_name}: {e}"
)
raise
async def drop_pending_index_ops(self) -> None:
"""Discard buffered upserts on an aborting batch.
Only the pending buffer is dropped; records already materialized into
``self._client`` by a prior ``_flush_pending_locked`` whose save step
then failed (``_client_dirty=True``) are intentionally NOT rolled back.
The pipeline treats each file as an atomic unit: an abort marks the
affected documents FAILED and the whole file is reprocessed on the
next run. Because upserts are keyed by deterministic ids (entity-name
/ relation / chunk hashes), reprocessing overwrites those vectors
idempotently, so the final state is identical whether or not we roll
back here. This matches the server-backed backends (Milvus / OpenSearch
/ Postgres / Mongo / Qdrant), which likewise keep a sibling flush's
already-committed partial data on abort rather than rolling it back;
and if the process crashes before the next save, these in-memory
writes are dropped anyway. Rolling back only FAISS/Nano would add an
inconsistent, non-load-bearing "FAILED == clean" guarantee, so it is
deliberately omitted.
"""
if self._storage_lock is None:
self._pending_upserts.clear()
return
async with self._storage_lock:
self._pending_upserts.clear()
async def index_done_callback(self) -> bool:
"""Flush deferred embeddings, commit to disk, and notify other processes.
This is the writer's **commit point** in the cross-process sync
protocol (see class docstring). Effects, in order:
1. If another process committed first, reload the latest on-disk
snapshot while preserving this process's pending buffer.
2. ``_flush_pending_locked`` embeds every buffered upsert (once
per id) and materializes it into ``self._client``. A failure
here **raises** — pending is kept, nothing is written.
3. ``_save_to_disk_locked`` (``atomic_write``) lays a tmp file
beside the target and renames it into place — readers either
see the previous file in full or the new file in full, never a
torn write. A failure here **also raises**; ``_client_dirty``
stays ``True`` so a later ``finalize`` retries the save.
4. ``set_all_update_flags`` flips every registered process's
``storage_updated`` flag, then we immediately reset our own
flag to ``False`` so the writer does not self-reload on the
next call to ``_get_client``.
Either failure surfaces loudly through ``_insert_done`` so the caller
can abort the document batch instead of silently losing vectors. The
bool return is kept for legacy callers but is effectively always
``True`` on the success path.
"""
async with self._storage_lock:
self._reload_client_from_disk_locked(for_write=True)
# Flush + save both raise on failure (embedding mismatch / save IO
# error). The exception propagates out of the lock so _insert_done
# aborts the batch; pending stays intact and _client_dirty stays
# True (if only the save failed) for a later retry.
await self._flush_pending_locked()
self._save_to_disk_locked()
await set_all_update_flags(self.namespace, workspace=self.workspace)
self.storage_updated.value = False
self._client_dirty = False
return True
@staticmethod
def _format_record(dp: dict[str, Any]) -> dict[str, Any]:
"""Shape a stored/pending record into the public read result."""
return {
**{k: v for k, v in dp.items() if k not in ("vector", "__vector__")},
"id": dp.get("__id__"),
"created_at": dp.get("__created_at__"),
}
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID (read-your-writes against the pending buffer).
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
# Read-your-writes: a buffered upsert is visible before its flush.
async with self._storage_lock:
pending = self._pending_upserts.get(id)
if pending is not None:
return self._format_record(pending.record)
client = await self._get_client()
result = client.get([id])
if result:
return self._format_record(result[0])
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs (read-your-writes), preserving order.
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
# Read-your-writes: serve buffered upserts from the pending area and
# only query the materialized client for the remaining ids.
result_map: dict[str, dict[str, Any]] = {}
remaining: list[str] = []
async with self._storage_lock:
for requested_id in ids:
pending = self._pending_upserts.get(requested_id)
if pending is not None:
result_map[str(requested_id)] = self._format_record(pending.record)
else:
remaining.append(requested_id)
if remaining:
client = await self._get_client()
for dp in client.get(remaining):
if not dp:
continue
record = self._format_record(dp)
key = record.get("id")
if key is not None:
result_map[str(key)] = record
return [result_map.get(str(requested_id)) for requested_id in ids]
async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:
"""Get vectors by their IDs (read-your-writes), returning only ID and vector.
For buffered upserts the vector is computed lazily (and cached back onto
the pending doc so the next flush reuses it instead of re-embedding);
for materialized rows the stored compressed vector is decoded.
Args:
ids: List of unique identifiers
Returns:
Dictionary mapping IDs to their vector embeddings
Format: {id: [vector_values], ...}
"""
if not ids:
return {}
vectors_dict: dict[str, list[float]] = {}
remaining: list[str] = []
async with self._storage_lock:
to_embed: list[tuple[str, _PendingNanoDoc]] = []
for requested_id in ids:
pending = self._pending_upserts.get(requested_id)
if pending is None:
remaining.append(requested_id)
elif pending.vector is not None:
vectors_dict[requested_id] = pending.vector.astype(
np.float32
).tolist()
else:
to_embed.append((requested_id, pending))
if to_embed:
contents = [pdoc.record["content"] for _, pdoc in to_embed]
batches = [
contents[i : i + self._max_batch_size]
for i in range(0, len(contents), self._max_batch_size)
]
embeddings_list = await asyncio.gather(
*[
self.embedding_func(batch, context="document")
for batch in batches
]
)
embeddings = np.concatenate(embeddings_list)
if len(embeddings) != len(to_embed):
raise RuntimeError(
f"[{self.workspace}] embedding is not 1-1 with pending data, "
f"{len(embeddings)} != {len(to_embed)}"
)
for (requested_id, pdoc), embedding in zip(to_embed, embeddings):
# Cache the vector back so the next flush reuses it.
pdoc.vector = embedding
vectors_dict[requested_id] = embedding.astype(np.float32).tolist()
if remaining:
client = await self._get_client()
for result in client.get(remaining):
if result and "vector" in result and "__id__" in result:
# Decompress vector data (Base64 + zlib + Float16 compressed)
decoded = base64.b64decode(result["vector"])
decompressed = zlib.decompress(decoded)
vector_f16 = np.frombuffer(decompressed, dtype=np.float16)
vector_f32 = vector_f16.astype(np.float32).tolist()
vectors_dict[result["__id__"]] = vector_f32
return vectors_dict
async def drop(self) -> dict[str, str]:
"""Drop all vector data from storage and reinitialize the client.
This method will:
1. Remove the vector database storage file if it exists
2. Reinitialize the vector database client
3. Update flags to notify other processes
4. Changes are persisted to disk immediately
Caller contract:
``drop`` is destructive and **not** serialized by this storage
class. The caller must hold the pipeline ``busy`` reservation
(the ``/documents/clear`` endpoint does this) before invoking
it — running ``drop`` concurrently with an active document
pipeline will tear down storage out from under the writer and
silently lose data. See class docstring,
*Non-pipeline write paths*.
Returns:
dict[str, str]: Operation status and message
- On success: {"status": "success", "message": "data dropped"}
- On failure: {"status": "error", "message": "<error details>"}
"""
try:
async with self._storage_lock:
# Discard buffered (unflushed) upserts along with the data.
self._pending_upserts.clear()
# delete _client_file_name
if os.path.exists(self._client_file_name):
os.remove(self._client_file_name)
self._client = NanoVectorDB(
self.embedding_func.embedding_dim,
storage_file=self._client_file_name,
)
self._client_dirty = False
# Notify other processes that data has been updated
await set_all_update_flags(self.namespace, workspace=self.workspace)
# Reset own update flag to avoid self-reloading
self.storage_updated.value = False
logger.info(
f"[{self.workspace}] Process {os.getpid()} drop {self.namespace}(file:{self._client_file_name})"
)
return {"status": "success", "message": "data dropped"}
except Exception as e:
logger.error(f"[{self.workspace}] Error dropping {self.namespace}: {e}")
return {"status": "error", "message": str(e)}
async def finalize(self):
"""Flush any buffered upserts and persist before shutdown (safety net).
Normally ``index_done_callback`` has already drained the pending buffer
and synced to disk, but two paths land here with work to do:
- **Pending upserts only** (no prior ``index_done_callback``): flush
and save. We reload first so a stale process picks up other writers'
commits before merging its pending buffer in.
- **Unsaved materialized changes** (``_client_dirty=True``): an earlier
``index_done_callback`` flushed pending into ``self._client`` but
its save raised. Skip the reload — reloading would drop those
materialized-but-unsaved rows — and just retry the save.
Flush / save failures propagate (same contract as
``index_done_callback``); a partially flushed buffer is preserved for
a future retry.
"""
async with self._storage_lock:
if not self._pending_upserts and not self._client_dirty:
return
if self._pending_upserts:
# Only reload when we have nothing un-persisted in self._client.
# A dirty client carries successfully-flushed-but-unsaved rows
# from a prior index_done_callback; reloading would silently
# drop them.
if not self._client_dirty:
self._reload_client_from_disk_locked(for_write=True)
await self._flush_pending_locked()
self._save_to_disk_locked()
await set_all_update_flags(self.namespace, workspace=self.workspace)
self.storage_updated.value = False
self._client_dirty = False
File diff suppressed because it is too large Load Diff
+796
View File
@@ -0,0 +1,796 @@
import os
from collections import deque
from dataclasses import dataclass
from typing import final
from lightrag.file_atomic import atomic_write, reap_orphan_tmp_files
from lightrag.types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge
from lightrag.utils import logger, validate_workspace
from lightrag.base import BaseGraphStorage
import networkx as nx
from .shared_storage import (
get_namespace_lock,
get_update_flag,
set_all_update_flags,
)
from dotenv import load_dotenv
# use the .env that is inside the current folder
# allows to use different .env file for each lightrag instance
# the OS environment variables take precedence over the .env file
load_dotenv(dotenv_path=".env", override=False)
@final
@dataclass
class NetworkXStorage(BaseGraphStorage):
"""File-backed knowledge-graph storage built on ``networkx.Graph``.
Storage model:
A single ``networkx.Graph`` instance lives in process memory; its
full state is serialized to one GraphML file at
``working_dir/[workspace/]graph_<namespace>.graphml``. That GraphML
file is the **only** cross-process synchronization surface — there
is no shared memory, no message bus, and no network channel
between processes. Cross-process visibility is mediated by (a) an
atomic file write at commit time and (b) a per-namespace
``storage_updated`` flag distributed through
``lightrag.kg.shared_storage``.
Concurrency invariants (the code in this file is correct *only* while
all three hold):
1. **Single writer per workspace.** The document pipeline's
``busy`` / ``destructive_busy`` flags (see ``AGENTS.md``
*Pipeline concurrency contract*) guarantee at most one process
performs ``upsert_*`` / ``delete_*`` / ``remove_*`` /
``index_done_callback`` at any time. Every other process is
read-only.
2. **Eventual consistency is sufficient.** Read-only processes
only need to observe the writer's data *after* the writer's
``index_done_callback`` completes. Reads landing in the gap
between a writer's in-memory mutation and its commit may
legitimately return the pre-update snapshot.
3. **networkx operations are fully synchronous.** Under a
single-threaded asyncio event loop, ``graph.add_node`` /
``graph.remove_node`` / ``graph.degree`` / etc. cannot be
preempted by another coroutine, which gives them implicit
mutual exclusion over ``self._graph``. This is why the methods
below don't have to hold ``_storage_lock`` while calling into
``graph``.
Cross-process sync protocol (identical in shape to
``NanoVectorDBStorage`` — see that class's docstring for the canonical
description):
Writer side (``index_done_callback``):
1. ``write_nx_graph`` atomically writes the GraphML file
(``atomic_write`` lays a tmp file beside the target and
renames it into place — readers either see the previous
file in full or the new file in full, never a torn write).
2. ``set_all_update_flags`` flips every process's
``storage_updated`` flag (including the writer's own).
3. Immediately reset the writer's own flag to ``False`` so
the next call to ``_get_graph`` does not trigger a
self-reload of the data this process just wrote.
Reader side (any method that goes through ``_get_graph``):
1. Inside ``_storage_lock``, observe
``storage_updated.value is True``.
2. **Fully reload** ``self._graph`` from disk via
``load_nx_graph``. networkx GraphML has no incremental
sync API, so the entire file is re-parsed.
3. Reset the reader's own flag.
Lock scope:
``_storage_lock`` is a per-``(namespace, workspace)`` keyed lock
spanning both intra-process coroutines and inter-process workers.
It wraps only the *reload* and *commit* critical sections, not
every ``graph.xxx`` call. Operating on ``graph`` outside the lock
is safe today *because of invariant (3)* — if either premise is
ever broken (e.g. ``graph.xxx`` is moved to a thread pool, or
networkx is swapped for an async graph library), the lock scope
must be widened to cover the mutation/read itself.
Implementation differences from ``NanoVectorDBStorage`` (same design,
different surface):
* No ``client_storage`` property — there is no equivalent live
reference being exposed to callers, so NanoVectorDB's
"do-not-retain-across-await" caveat does not apply here.
* ``write_nx_graph`` passes the tmp path directly to
``nx.write_graphml``, so the writer needs no equivalent of
NanoVectorDB's "temporarily reassign ``storage_file``" trick.
* Mutation surface is finer-grained (``upsert_node`` /
``upsert_edge`` / ``upsert_nodes_batch`` /
``upsert_edges_batch`` / ``delete_node`` / ``remove_nodes`` /
``remove_edges``); each goes through ``_get_graph`` once and
then operates synchronously on ``self._graph``.
Non-pipeline write paths:
The pipeline's ``busy`` gate serializes mutation calls reached
through the document ingestion and purge flows. The following
entry points are **not** serialized by the pipeline gate and
must be guarded externally:
* ``drop`` — currently gated by the API layer (the
``/documents/clear`` endpoint takes the pipeline busy
reservation before invoking it).
* ``delete_node`` / ``remove_nodes`` / ``remove_edges`` /
``upsert_node`` / ``upsert_edge`` when invoked from
``utils_graph.py`` admin flows (``adelete_by_entity`` /
``adelete_by_relation`` / entity-edit flows). These flows
are currently not exposed in the WebUI; any future caller
must arrange single-writer serialization the same way the
pipeline does.
"""
@staticmethod
def load_nx_graph(file_name) -> nx.Graph:
if os.path.exists(file_name):
return nx.read_graphml(file_name)
return None
@staticmethod
def write_nx_graph(graph: nx.Graph, file_name, workspace="_"):
logger.info(
f"[{workspace}] Writing graph with {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges"
)
atomic_write(
file_name,
lambda tmp: nx.write_graphml(graph, tmp),
workspace,
)
def __post_init__(self):
# Reject path traversal before using workspace in a file path
validate_workspace(self.workspace)
working_dir = self.global_config["working_dir"]
if self.workspace:
# Include workspace in the file path for data isolation
workspace_dir = os.path.join(working_dir, self.workspace)
else:
# Default behavior when workspace is empty
workspace_dir = working_dir
self.workspace = ""
os.makedirs(workspace_dir, exist_ok=True)
self._graphml_xml_file = os.path.join(
workspace_dir, f"graph_{self.namespace}.graphml"
)
self._storage_lock = None
self.storage_updated = None
self._graph = None
reap_orphan_tmp_files(self._graphml_xml_file, workspace=self.workspace or "_")
# Load initial graph
preloaded_graph = NetworkXStorage.load_nx_graph(self._graphml_xml_file)
if preloaded_graph is not None:
logger.info(
f"[{self.workspace}] Loaded graph from {self._graphml_xml_file} with {preloaded_graph.number_of_nodes()} nodes, {preloaded_graph.number_of_edges()} edges"
)
else:
logger.info(
f"[{self.workspace}] Created new empty graph file: {self._graphml_xml_file}"
)
self._graph = preloaded_graph or nx.Graph()
async def initialize(self):
"""Initialize storage data"""
# Get the update flag for cross-process update notification
self.storage_updated = await get_update_flag(
self.namespace, workspace=self.workspace
)
# Get the storage lock for use in other methods
self._storage_lock = get_namespace_lock(
self.namespace, workspace=self.workspace
)
async def _get_graph(self):
"""Return the live ``networkx.Graph``, reloading from disk if needed.
This is the **single entry point** every public method funnels
through to obtain ``self._graph``. It is also the **only place
readers transition to a fresher on-disk snapshot**: when another
process has committed (via ``index_done_callback``) and flipped
this process's ``storage_updated`` flag, the next call here
rebuilds ``self._graph`` by re-parsing the entire GraphML file.
networkx has no incremental sync API — the reload is
unconditionally a full file reload.
Under the *Single writer* invariant (see class docstring), the
reload branch never fires in the writer process: the writer
resets its own flag at the end of every ``index_done_callback``.
The branch exists for readers.
``_storage_lock`` is held during the check-and-reload to (a)
serialize concurrent reload attempts by sibling coroutines in
the same process and (b) interlock with ``index_done_callback``
so a reader cannot observe a partially-saved file.
"""
async with self._storage_lock:
# Check if data needs to be reloaded
if self.storage_updated.value:
logger.info(
f"[{self.workspace}] Process {os.getpid()} reloading graph {self._graphml_xml_file} due to modifications by another process"
)
# Reload data
self._graph = (
NetworkXStorage.load_nx_graph(self._graphml_xml_file) or nx.Graph()
)
# Reset update flag
self.storage_updated.value = False
return self._graph
async def has_node(self, node_id: str) -> bool:
graph = await self._get_graph()
return graph.has_node(node_id)
async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:
graph = await self._get_graph()
return graph.has_edge(source_node_id, target_node_id)
async def get_node(self, node_id: str) -> dict[str, str] | None:
graph = await self._get_graph()
return graph.nodes.get(node_id)
async def node_degree(self, node_id: str) -> int:
graph = await self._get_graph()
if graph.has_node(node_id):
return graph.degree(node_id)
return 0
async def edge_degree(self, src_id: str, tgt_id: str) -> int:
graph = await self._get_graph()
src_degree = graph.degree(src_id) if graph.has_node(src_id) else 0
tgt_degree = graph.degree(tgt_id) if graph.has_node(tgt_id) else 0
return src_degree + tgt_degree
async def get_edge(
self, source_node_id: str, target_node_id: str
) -> dict[str, str] | None:
graph = await self._get_graph()
return graph.edges.get((source_node_id, target_node_id))
async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:
graph = await self._get_graph()
if graph.has_node(source_node_id):
return list(graph.edges(source_node_id))
return None
async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:
"""Insert or update a single node; persistence is deferred.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. In ``lightrag.py`` this
is handled by ``_insert_done()`` at the end of the document
batch. Callers outside the pipeline must persist explicitly.
Correctness relies on the class docstring *Lock scope* invariant
(synchronous networkx ops + single-writer pipeline gate).
"""
graph = await self._get_graph()
graph.add_node(node_id, **node_data)
async def upsert_edge(
self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]
) -> None:
"""Insert or update a single edge; persistence is deferred.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. Callers outside the
pipeline must persist explicitly.
Correctness relies on the class docstring *Lock scope* invariant.
"""
graph = await self._get_graph()
graph.add_edge(source_node_id, target_node_id, **edge_data)
async def upsert_nodes_batch(self, nodes: list[tuple[str, dict[str, str]]]) -> None:
"""Batch insert/update multiple nodes in a single call.
Much faster than calling upsert_node() in a loop for large imports
because it avoids per-call async event loop overhead.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. Callers outside the
pipeline must persist explicitly.
Args:
nodes: List of (node_id, node_data) tuples.
"""
graph = await self._get_graph()
for node_id, node_data in nodes:
graph.add_node(node_id, **node_data)
async def has_nodes_batch(self, node_ids: list[str]) -> set[str]:
"""Check existence of multiple nodes in a single call.
Returns:
Set of node_ids that exist in the graph.
"""
graph = await self._get_graph()
return {nid for nid in node_ids if graph.has_node(nid)}
async def upsert_edges_batch(
self, edges: list[tuple[str, str, dict[str, str]]]
) -> None:
"""Batch insert/update multiple edges in a single call.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. Callers outside the
pipeline must persist explicitly.
Args:
edges: List of (source_id, target_id, edge_data) tuples.
"""
graph = await self._get_graph()
for src, tgt, edge_data in edges:
graph.add_edge(src, tgt, **edge_data)
async def delete_node(self, node_id: str) -> None:
"""Remove a single node from the graph; persistence is deferred.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. Callers outside the
pipeline must persist explicitly.
Pipeline-gating depends on the caller: invocations from the
document purge flow are serialized by ``pipeline busy``;
invocations from ``utils_graph.py`` admin flows are **not** —
see class docstring *Non-pipeline write paths*.
"""
graph = await self._get_graph()
if graph.has_node(node_id):
graph.remove_node(node_id)
logger.debug(f"[{self.workspace}] Node {node_id} deleted from the graph")
else:
logger.warning(
f"[{self.workspace}] Node {node_id} not found in the graph for deletion"
)
async def remove_nodes(self, nodes: list[str]):
"""Delete multiple nodes from the graph.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. Callers outside the
pipeline must persist explicitly.
Pipeline-gating depends on the caller — see ``delete_node`` and
class docstring *Non-pipeline write paths*.
Args:
nodes: List of node IDs to be deleted
"""
graph = await self._get_graph()
for node in nodes:
if graph.has_node(node):
graph.remove_node(node)
async def remove_edges(self, edges: list[tuple[str, str]]):
"""Delete multiple edges from the graph.
Persistence:
Changes are in-memory only; cross-process visibility requires
a subsequent ``index_done_callback``. Callers outside the
pipeline must persist explicitly.
Pipeline-gating depends on the caller — see ``delete_node`` and
class docstring *Non-pipeline write paths*.
Args:
edges: List of edges to be deleted, each edge is a (source, target) tuple
"""
graph = await self._get_graph()
for source, target in edges:
if graph.has_edge(source, target):
graph.remove_edge(source, target)
async def get_all_labels(self) -> list[str]:
"""
Get all node labels(entity names) in the graph
Returns:
[label1, label2, ...] # Alphabetically sorted label list
"""
graph = await self._get_graph()
labels = set()
for node in graph.nodes():
labels.add(str(node)) # Add node id as a label
# Return sorted list
return sorted(list(labels))
async def get_popular_labels(self, limit: int = 300) -> list[str]:
"""
Get popular labels(entity names) by node degree (most connected entities)
Args:
limit: Maximum number of labels to return
Returns:
List of labels sorted by degree (highest first)
"""
graph = await self._get_graph()
# Get degrees of all nodes and sort by degree descending
degrees = dict(graph.degree())
sorted_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)
# Return top labels limited by the specified limit
popular_labels = [str(node) for node, _ in sorted_nodes[:limit]]
logger.debug(
f"[{self.workspace}] Retrieved {len(popular_labels)} popular labels (limit: {limit})"
)
return popular_labels
async def search_labels(self, query: str, limit: int = 50) -> list[str]:
"""
Search labels(entity names) with fuzzy matching
Args:
query: Search query string
limit: Maximum number of results to return
Returns:
List of matching labels sorted by relevance
"""
graph = await self._get_graph()
query_lower = query.lower().strip()
if not query_lower:
return []
# Collect matching nodes with relevance scores
matches = []
for node in graph.nodes():
node_str = str(node)
node_lower = node_str.lower()
# Skip if no match
if query_lower not in node_lower:
continue
# Calculate relevance score
# Exact match gets highest score
if node_lower == query_lower:
score = 1000
# Prefix match gets high score
elif node_lower.startswith(query_lower):
score = 500
# Contains match gets base score, with bonus for shorter strings
else:
# Shorter strings with matches are more relevant
score = 100 - len(node_str)
# Bonus for word boundary matches
if f" {query_lower}" in node_lower or f"_{query_lower}" in node_lower:
score += 50
matches.append((node_str, score))
# Sort by relevance score (desc) then alphabetically
matches.sort(key=lambda x: (-x[1], x[0]))
# Return top matches limited by the specified limit
search_results = [match[0] for match in matches[:limit]]
logger.debug(
f"[{self.workspace}] Search query '{query}' returned {len(search_results)} results (limit: {limit})"
)
return search_results
async def get_knowledge_graph(
self,
node_label: str,
max_depth: int = 3,
max_nodes: int = None,
) -> KnowledgeGraph:
"""
Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.
Args:
node_label: Label of the starting node* means all nodes
max_depth: Maximum depth of the subgraph, Defaults to 3
max_nodes: Maxiumu nodes to return by BFS, Defaults to 1000
Returns:
KnowledgeGraph object containing nodes and edges, with an is_truncated flag
indicating whether the graph was truncated due to max_nodes limit
"""
# Get max_nodes from global_config if not provided
if max_nodes is None:
max_nodes = self.global_config.get("max_graph_nodes", 1000)
else:
# Limit max_nodes to not exceed global_config max_graph_nodes
max_nodes = min(max_nodes, self.global_config.get("max_graph_nodes", 1000))
graph = await self._get_graph()
result = KnowledgeGraph()
# Handle special case for "*" label
if node_label == "*":
# Get degrees of all nodes
degrees = dict(graph.degree())
# Sort nodes by degree in descending order and take top max_nodes
sorted_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)
# Check if graph is truncated
if len(sorted_nodes) > max_nodes:
result.is_truncated = True
logger.info(
f"[{self.workspace}] Graph truncated: {len(sorted_nodes)} nodes found, limited to {max_nodes}"
)
limited_nodes = [node for node, _ in sorted_nodes[:max_nodes]]
# Create subgraph with the highest degree nodes
subgraph = graph.subgraph(limited_nodes)
else:
# Check if node exists
if node_label not in graph:
logger.warning(
f"[{self.workspace}] Node {node_label} not found in the graph"
)
return KnowledgeGraph() # Return empty graph
# Use modified BFS to get nodes, prioritizing high-degree nodes at the same depth
bfs_nodes = []
visited = set()
# Store (node, depth, degree) in the queue
queue = deque([(node_label, 0, graph.degree(node_label))])
# Flag to track if there are unexplored neighbors due to depth limit
has_unexplored_neighbors = False
# Modified breadth-first search with degree-based prioritization
while queue and len(bfs_nodes) < max_nodes:
# Get the current depth from the first node in queue
current_depth = queue[0][1]
# Collect all nodes at the current depth
current_level_nodes = []
while queue and queue[0][1] == current_depth:
current_level_nodes.append(queue.popleft())
# Sort nodes at current depth by degree (highest first)
current_level_nodes.sort(key=lambda x: x[2], reverse=True)
# Process all nodes at current depth in order of degree
for current_node, depth, degree in current_level_nodes:
if current_node not in visited:
visited.add(current_node)
bfs_nodes.append(current_node)
# Only explore neighbors if we haven't reached max_depth
if depth < max_depth:
# Add neighbor nodes to queue with incremented depth
neighbors = list(graph.neighbors(current_node))
# Filter out already visited neighbors
unvisited_neighbors = [
n for n in neighbors if n not in visited
]
# Add neighbors to the queue with their degrees
for neighbor in unvisited_neighbors:
neighbor_degree = graph.degree(neighbor)
queue.append((neighbor, depth + 1, neighbor_degree))
else:
# Check if there are unexplored neighbors (skipped due to depth limit)
neighbors = list(graph.neighbors(current_node))
unvisited_neighbors = [
n for n in neighbors if n not in visited
]
if unvisited_neighbors:
has_unexplored_neighbors = True
# Check if we've reached max_nodes
if len(bfs_nodes) >= max_nodes:
break
# Check if graph is truncated - either due to max_nodes limit or depth limit
if (queue and len(bfs_nodes) >= max_nodes) or has_unexplored_neighbors:
if len(bfs_nodes) >= max_nodes:
result.is_truncated = True
logger.info(
f"[{self.workspace}] Graph truncated: max_nodes limit {max_nodes} reached"
)
else:
logger.info(
f"[{self.workspace}] Graph truncated: found {len(bfs_nodes)} nodes within max_depth {max_depth}"
)
# Create subgraph with BFS discovered nodes
subgraph = graph.subgraph(bfs_nodes)
# Add nodes to result
seen_nodes = set()
seen_edges = set()
for node in subgraph.nodes():
if str(node) in seen_nodes:
continue
node_data = dict(subgraph.nodes[node])
# Get entity_type as labels
labels = []
if "entity_type" in node_data:
if isinstance(node_data["entity_type"], list):
labels.extend(node_data["entity_type"])
else:
labels.append(node_data["entity_type"])
# Create node with properties
node_properties = {k: v for k, v in node_data.items()}
result.nodes.append(
KnowledgeGraphNode(
id=str(node), labels=[str(node)], properties=node_properties
)
)
seen_nodes.add(str(node))
# Add edges to result
for edge in subgraph.edges():
source, target = edge
# Esure unique edge_id for undirect graph
if str(source) > str(target):
source, target = target, source
edge_id = f"{source}-{target}"
if edge_id in seen_edges:
continue
edge_data = dict(subgraph.edges[edge])
# Create edge with complete information
result.edges.append(
KnowledgeGraphEdge(
id=edge_id,
type="DIRECTED",
source=str(source),
target=str(target),
properties=edge_data,
)
)
seen_edges.add(edge_id)
logger.info(
f"[{self.workspace}] Subgraph query successful | Node count: {len(result.nodes)} | Edge count: {len(result.edges)}"
)
return result
async def get_all_nodes(self) -> list[dict]:
"""Get all nodes in the graph.
Returns:
A list of all nodes, where each node is a dictionary of its properties
"""
graph = await self._get_graph()
all_nodes = []
for node_id, node_data in graph.nodes(data=True):
node_data_with_id = node_data.copy()
node_data_with_id["id"] = node_id
all_nodes.append(node_data_with_id)
return all_nodes
async def get_all_edges(self) -> list[dict]:
"""Get all edges in the graph.
Returns:
A list of all edges, where each edge is a dictionary of its properties
"""
graph = await self._get_graph()
all_edges = []
for u, v, edge_data in graph.edges(data=True):
edge_data_with_nodes = edge_data.copy()
edge_data_with_nodes["source"] = u
edge_data_with_nodes["target"] = v
all_edges.append(edge_data_with_nodes)
return all_edges
async def index_done_callback(self) -> bool:
"""Commit in-memory graph to disk and notify other processes.
This is the writer's **commit point** in the cross-process sync
protocol (see class docstring). Two effects, in order:
1. ``write_nx_graph`` atomically writes the GraphML file
(``atomic_write`` swaps a tmp file into place).
2. ``set_all_update_flags`` flips every registered process's
``storage_updated`` flag, then we immediately reset our
own flag to ``False`` so the writer does not self-reload
on the next call to ``_get_graph``.
Two-block structure (intentional, do not collapse):
* **First ``async with``** — early-return path for a
hypothetical second writer. Under the current single-writer
pipeline contract (class docstring, invariant 1) the
``storage_updated.value`` check is permanently ``False`` in
the writer, so this branch is **dead code in production**.
It is kept as defensive scaffolding for any future
relaxation of the single-writer invariant; removing it
would silently re-enable lost-write bugs the moment a
second writer is introduced.
* **Second ``async with``** — the actual save + notify.
"""
async with self._storage_lock:
# Check if storage was updated by another process
if self.storage_updated.value:
# Storage was updated by another process, reload data instead of saving
logger.info(
f"[{self.workspace}] Graph was updated by another process, reloading..."
)
self._graph = (
NetworkXStorage.load_nx_graph(self._graphml_xml_file) or nx.Graph()
)
# Reset update flag
self.storage_updated.value = False
return False # Return error
# Acquire lock and perform persistence
async with self._storage_lock:
try:
# Save data to disk
NetworkXStorage.write_nx_graph(
self._graph, self._graphml_xml_file, self.workspace
)
# Notify other processes that data has been updated
await set_all_update_flags(self.namespace, workspace=self.workspace)
# Reset own update flag to avoid self-reloading
self.storage_updated.value = False
return True # Return success
except Exception as e:
# Raise (do NOT swallow + return False): _insert_done's
# _flush_one only detects failures via exceptions, so a
# swallowed graph-save error would let the document be marked
# PROCESSED with the graph changes unpersisted. Surfacing it
# aligns this backend with the others (faiss/nano raise too).
logger.error(f"[{self.workspace}] Error saving graph: {e}")
raise
return True
async def drop(self) -> dict[str, str]:
"""Drop all graph data from storage and reinitialize the graph.
This method will:
1. Remove the graph storage file if it exists
2. Reset the graph to an empty ``nx.Graph()``
3. Update flags to notify other processes
4. Changes are persisted to disk immediately
Caller contract:
``drop`` is destructive and **not** serialized by this storage
class. The caller must hold the pipeline ``busy`` reservation
(the ``/documents/clear`` endpoint does this) before invoking
it — running ``drop`` concurrently with an active document
pipeline will tear down storage out from under the writer and
silently lose data. See class docstring,
*Non-pipeline write paths*.
Returns:
dict[str, str]: Operation status and message
- On success: {"status": "success", "message": "data dropped"}
- On failure: {"status": "error", "message": "<error details>"}
"""
try:
async with self._storage_lock:
# delete _client_file_name
if os.path.exists(self._graphml_xml_file):
os.remove(self._graphml_xml_file)
self._graph = nx.Graph()
# Notify other processes that data has been updated
await set_all_update_flags(self.namespace, workspace=self.workspace)
# Reset own update flag to avoid self-reloading
self.storage_updated.value = False
logger.info(
f"[{self.workspace}] Process {os.getpid()} drop graph file:{self._graphml_xml_file}"
)
return {"status": "success", "message": "data dropped"}
except Exception as e:
logger.error(
f"[{self.workspace}] Error dropping graph file:{self._graphml_xml_file}: {e}"
)
return {"status": "error", "message": str(e)}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4469
View File
File diff suppressed because it is too large Load Diff
View File
+301
View File
@@ -0,0 +1,301 @@
"""Shared image-input normalization for LLM bindings.
All LLM bindings accept a unified ``image_inputs`` keyword parameter. Each
element may be:
- a raw base64 string (the MIME type is inferred via ``imghdr`` / magic bytes,
defaulting to ``image/png``);
- a data URL of the form ``data:<mime>;base64,<payload>``;
- a dict with keys ``base64`` (required) and optional ``mime_type``,
``source_id``, ``source_file``, ``modality``, ``doc_id``.
The provider-specific binding code converts the normalized result to its own
content-block format. The VLM pipeline uses :func:`image_cache_metadata` for
cache-key inputs (deliberately excluding ``source_id`` / ``source_file`` so the
same image at different filenames still hits the same entry) and
:func:`image_audit_metadata` for the human-readable ``original_prompt`` audit
block.
"""
from __future__ import annotations
import base64
import hashlib
import re
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DATA_URL_RE = re.compile(
r"^data:(?P<mime>[\w./+-]+);base64,(?P<data>[A-Za-z0-9+/=\s]+)$"
)
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_JPEG_SIGNATURE = b"\xff\xd8\xff"
_GIF_SIGNATURES = (b"GIF87a", b"GIF89a")
_WEBP_RIFF = b"RIFF"
_WEBP_TAG = b"WEBP"
@dataclass(frozen=True)
class NormalizedImage:
index: int
raw_bytes: bytes
mime_type: str
sha256: str
base64_str: str
source_id: str | None
source_file: str | None
modality: str | None
doc_id: str | None
# Pixel dimensions parsed from the raster header (None when the format
# is recognized but dimensions could not be extracted).
width: int | None = None
height: int | None = None
def _detect_mime(raw: bytes) -> str:
if raw.startswith(_PNG_SIGNATURE):
return "image/png"
if raw.startswith(_JPEG_SIGNATURE):
return "image/jpeg"
if any(raw.startswith(sig) for sig in _GIF_SIGNATURES):
return "image/gif"
if len(raw) >= 12 and raw[0:4] == _WEBP_RIFF and raw[8:12] == _WEBP_TAG:
return "image/webp"
return "image/png"
def _decode_base64(data: str) -> bytes:
cleaned = re.sub(r"\s+", "", data)
try:
return base64.b64decode(cleaned, validate=True)
except (base64.binascii.Error, ValueError) as exc:
raise ValueError(f"invalid base64 image data: {exc}") from exc
def _coerce_item(item: Any) -> dict[str, Any]:
if isinstance(item, str):
match = DATA_URL_RE.match(item.strip())
if match:
return {"base64": match.group("data"), "mime_type": match.group("mime")}
return {"base64": item}
if isinstance(item, dict):
if "base64" not in item:
raise ValueError("image_inputs dict element must contain a 'base64' key")
return item
raise TypeError(
f"image_inputs element must be str or dict, got {type(item).__name__}"
)
def normalize_image_inputs(
image_inputs: list[Any] | None,
) -> list[NormalizedImage]:
"""Normalize the unified ``image_inputs`` parameter.
Returns an empty list when ``image_inputs`` is falsy, so callers can do a
plain ``if normalized:`` check.
"""
if not image_inputs:
return []
result: list[NormalizedImage] = []
for idx, raw_item in enumerate(image_inputs):
item = _coerce_item(raw_item)
raw_bytes = _decode_base64(item["base64"])
if not raw_bytes:
raise ValueError(f"image_inputs[{idx}] decoded to empty bytes")
mime_type = item.get("mime_type") or _detect_mime(raw_bytes)
sha = hashlib.sha256(raw_bytes).hexdigest()
clean_b64 = base64.b64encode(raw_bytes).decode("ascii")
dims = _dimensions_from_bytes(raw_bytes)
width, height = (dims[0], dims[1]) if dims else (None, None)
result.append(
NormalizedImage(
index=idx,
raw_bytes=raw_bytes,
mime_type=mime_type,
sha256=sha,
base64_str=clean_b64,
source_id=item.get("source_id"),
source_file=item.get("source_file"),
modality=item.get("modality"),
doc_id=item.get("doc_id"),
width=width,
height=height,
)
)
return result
def image_cache_metadata(images: list[NormalizedImage]) -> list[dict[str, Any]]:
"""Return cache-key-safe image metadata (no source identifiers).
Includes ``width`` / ``height`` so the cache key reflects the full
image digest the design contract specifies (mime, sha256, bytes,
width, height). The sha256 alone is sufficient for identity, but
surfacing dimensions matches the documented audit shape and gives
diagnostics a one-line "what was sent" without re-decoding.
"""
return [
{
"index": img.index,
"mime_type": img.mime_type,
"sha256": img.sha256,
"bytes": len(img.raw_bytes),
"width": img.width,
"height": img.height,
}
for img in images
]
def image_audit_metadata(images: list[NormalizedImage]) -> list[dict[str, Any]]:
"""Return audit metadata suitable for the ``original_prompt`` block.
Never includes the raw base64 payload only digests and source pointers.
"""
return [
{
"index": img.index,
"mime_type": img.mime_type,
"sha256": img.sha256,
"bytes": len(img.raw_bytes),
"width": img.width,
"height": img.height,
"source_id": img.source_id,
"source_file": img.source_file,
"modality": img.modality,
"doc_id": img.doc_id,
}
for img in images
]
def _read_png_dimensions(data: bytes) -> tuple[int, int] | None:
# IHDR is the first chunk; width/height are big-endian uint32 at offsets
# 16/20 (8-byte signature + 4 length + 4 "IHDR" + 4 width + 4 height).
if len(data) < 24 or not data.startswith(_PNG_SIGNATURE):
return None
width, height = struct.unpack(">II", data[16:24])
return width, height
def _read_gif_dimensions(data: bytes) -> tuple[int, int] | None:
# Logical screen descriptor: width/height are little-endian uint16 at
# offsets 6/8.
if len(data) < 10 or not any(data.startswith(sig) for sig in _GIF_SIGNATURES):
return None
width, height = struct.unpack("<HH", data[6:10])
return width, height
def _read_jpeg_dimensions(data: bytes) -> tuple[int, int] | None:
# Scan for a Start-Of-Frame marker (SOF0 / SOF2 / etc.). Skip segments by
# their length field. We deliberately accept any SOF variant the codec
# might emit rather than enumerating each one.
if len(data) < 4 or not data.startswith(_JPEG_SIGNATURE):
return None
i = 2
n = len(data)
while i < n:
if data[i] != 0xFF:
return None
# Skip fill bytes.
while i < n and data[i] == 0xFF:
i += 1
if i >= n:
return None
marker = data[i]
i += 1
# Standalone markers without a length field.
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
continue
if i + 2 > n:
return None
segment_len = struct.unpack(">H", data[i : i + 2])[0]
if segment_len < 2 or i + segment_len > n:
return None
# SOF0..SOF15 except 0xC4 (DHT), 0xC8 (JPG reserved), 0xCC (DAC).
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
# SOF payload: precision(1) + height(2) + width(2) + …
if i + 7 > n:
return None
height, width = struct.unpack(">HH", data[i + 3 : i + 7])
return width, height
i += segment_len
return None
def _read_webp_dimensions(data: bytes) -> tuple[int, int] | None:
if len(data) < 30 or data[0:4] != _WEBP_RIFF or data[8:12] != _WEBP_TAG:
return None
chunk_type = data[12:16]
if chunk_type == b"VP8 ":
# Lossy: 3-byte tag + 3-byte sync code at offset 23, then 4 bytes
# holding 14-bit width / 14-bit height in little-endian halves.
if len(data) < 30:
return None
width = struct.unpack("<H", data[26:28])[0] & 0x3FFF
height = struct.unpack("<H", data[28:30])[0] & 0x3FFF
return width, height
if chunk_type == b"VP8L":
# Lossless: signature(0x2F) + 4 bytes encoding 14-bit width-1 / 14-bit
# height-1 starting at offset 21.
if len(data) < 25 or data[20] != 0x2F:
return None
b0, b1, b2, b3 = data[21], data[22], data[23], data[24]
width = ((b1 & 0x3F) << 8 | b0) + 1
height = ((b3 & 0x0F) << 10 | b2 << 2 | (b1 & 0xC0) >> 6) + 1
return width, height
if chunk_type == b"VP8X":
# Extended: 3 bytes width-1 / 3 bytes height-1, little-endian, at
# offsets 24/27.
if len(data) < 30:
return None
width = (data[24] | data[25] << 8 | data[26] << 16) + 1
height = (data[27] | data[28] << 8 | data[29] << 16) + 1
return width, height
return None
def read_image_dimensions(path: Path) -> tuple[int, int] | None:
"""Return ``(width, height)`` for a raster image, or ``None`` if unknown.
Reads only the file header no Pillow dependency. Supports PNG, JPEG,
GIF and WebP (VP8 / VP8L / VP8X). Returns ``None`` for unsupported
formats and on any I/O or parse error so callers can fall back to a
skipped/failure decision without raising.
"""
try:
with open(path, "rb") as fh:
header = fh.read(64 * 1024)
except OSError:
return None
return _dimensions_from_bytes(header)
def _dimensions_from_bytes(data: bytes) -> tuple[int, int] | None:
"""Run the four header readers against a byte buffer.
Shared between the file-path entry point (:func:`read_image_dimensions`)
and :func:`normalize_image_inputs`, which receives raster payloads
decoded from the unified ``image_inputs`` parameter.
"""
if not data:
return None
for reader in (
_read_png_dimensions,
_read_gif_dimensions,
_read_jpeg_dimensions,
_read_webp_dimensions,
):
try:
dims = reader(data)
except (struct.error, IndexError, ValueError):
continue
if dims:
return dims
return None
+376
View File
@@ -0,0 +1,376 @@
from ..utils import verbose_debug, VERBOSE_DEBUG
import sys
import os
import logging
import warnings
from typing import Any, Union, AsyncIterator
import pipmaster as pm # Pipmaster for dynamic library install
if sys.version_info < (3, 9):
from typing import AsyncIterator
else:
from collections.abc import AsyncIterator
# Install Anthropic SDK if not present
if not pm.is_installed("anthropic"):
pm.install("anthropic")
from anthropic import (
AsyncAnthropic,
APIConnectionError,
RateLimitError,
APITimeoutError,
)
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.utils import (
safe_unicode_decode,
logger,
)
from lightrag.api import __api_version__
# Custom exception for retry mechanism
class InvalidResponseError(Exception):
"""Custom exception class for triggering retry mechanism"""
pass
# Core Anthropic completion function with retry
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError, InvalidResponseError)
),
)
async def anthropic_complete_if_cache(
model: str,
prompt: str,
system_prompt: str | None = None,
history_messages: list[dict[str, Any]] | None = None,
enable_cot: bool = False,
base_url: str | None = None,
api_key: str | None = None,
image_inputs: list[Any] | None = None,
**kwargs: Any,
) -> Union[str, AsyncIterator[str]]:
"""Call Anthropic Messages API with LightRAG-compatible shims.
Structured output note:
- This adapter does not support OpenAI-style ``response_format`` JSON mode.
- If callers pass ``response_format``, it is stripped before the request.
- Deprecated ``keyword_extraction`` and ``entity_extraction`` booleans are
accepted only as compatibility shims; they emit warnings and are ignored.
"""
if history_messages is None:
history_messages = []
if enable_cot:
logger.debug(
"enable_cot=True is not supported for the Anthropic API and will be ignored."
)
if not api_key:
api_key = os.environ.get("ANTHROPIC_API_KEY")
default_headers = {
"User-Agent": f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) LightRAG/{__api_version__}",
"Content-Type": "application/json",
}
# Set logger level to INFO when VERBOSE_DEBUG is off
if not VERBOSE_DEBUG and logger.level == logging.DEBUG:
logging.getLogger("anthropic").setLevel(logging.INFO)
kwargs.pop("hashing_kv", None)
# Anthropic Messages API has no JSON mode; drop legacy flags and
# response_format. Emit DeprecationWarning when the booleans were set.
if kwargs.pop("keyword_extraction", False):
warnings.warn(
"anthropic_complete_if_cache(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
if kwargs.pop("entity_extraction", False):
warnings.warn(
"anthropic_complete_if_cache(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs.pop("response_format", None)
timeout = kwargs.pop("timeout", None)
# Require max_tokens; the Anthropic SDK errors if it's missing
kwargs.setdefault("max_tokens", 8192)
# Pop stream from kwargs so it doesn't leak into create_params;
# default to False (non-streaming) for consistency with other providers
stream = kwargs.pop("stream", False)
anthropic_async_client = (
AsyncAnthropic(
default_headers=default_headers, api_key=api_key, timeout=timeout
)
if base_url is None
else AsyncAnthropic(
base_url=base_url,
default_headers=default_headers,
api_key=api_key,
timeout=timeout,
)
)
messages: list[dict[str, Any]] = []
messages.extend(history_messages)
if image_inputs:
from lightrag.llm._vision_utils import normalize_image_inputs
normalized_images = normalize_image_inputs(image_inputs)
user_content: list[dict[str, Any]] = []
for img in normalized_images:
user_content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": img.mime_type,
"data": img.base64_str,
},
}
)
user_content.append({"type": "text", "text": prompt})
messages.append({"role": "user", "content": user_content})
else:
messages.append({"role": "user", "content": prompt})
logger.debug("===== Sending Query to Anthropic LLM =====")
logger.debug(f"Model: {model} Base URL: {base_url}")
logger.debug(f"Additional kwargs: {kwargs}")
verbose_debug(f"Query: {prompt}")
verbose_debug(f"System prompt: {system_prompt}")
try:
create_params = {
"model": model,
"messages": messages,
"stream": stream,
**kwargs,
}
if system_prompt:
create_params["system"] = system_prompt
response = await anthropic_async_client.messages.create(**create_params)
except APITimeoutError as e:
# APITimeoutError subclasses APIConnectionError, so it must come first
# or it would be swallowed by the broader handler below.
logger.error(f"Anthropic API Timeout Error: {e}")
try:
await anthropic_async_client.close()
except Exception as close_error:
logger.warning(f"Failed to close Anthropic client: {close_error}")
raise
except APIConnectionError as e:
logger.error(f"Anthropic API Connection Error: {e}")
try:
await anthropic_async_client.close()
except Exception as close_error:
logger.warning(f"Failed to close Anthropic client: {close_error}")
raise
except RateLimitError as e:
logger.error(f"Anthropic API Rate Limit Error: {e}")
try:
await anthropic_async_client.close()
except Exception as close_error:
logger.warning(f"Failed to close Anthropic client: {close_error}")
raise
except Exception as e:
body = getattr(e, "body", None)
request_id = getattr(e, "request_id", None)
req = getattr(e, "request", None)
extra_parts = []
if body:
extra_parts.append(f"Response body: {body}")
if request_id:
extra_parts.append(f"Request ID: {request_id}")
if req is not None:
extra_parts.append(f"Request URL: {req.url}")
extra = ("\n" + "\n".join(extra_parts)) if extra_parts else ""
logger.error(
f"Anthropic API Call Failed,\nModel: {model},\nParams: {kwargs}, Got: {e}{extra}"
)
try:
await anthropic_async_client.close()
except Exception as close_error:
logger.warning(f"Failed to close Anthropic client: {close_error}")
raise
except BaseException:
# CancelledError (and other BaseExceptions) aren't caught above, yet a
# cancellation while awaiting create() — before we hold the response or
# stream — would otherwise leak the client. Close it, then re-raise the
# cancellation untouched (cooperative cancellation).
try:
await anthropic_async_client.close()
except Exception as close_error:
logger.warning(f"Failed to close Anthropic client: {close_error}")
raise
if not stream:
try:
return response.content[0].text
finally:
try:
await anthropic_async_client.close()
except Exception as close_error:
logger.warning(f"Failed to close Anthropic client: {close_error}")
async def stream_response():
try:
async for event in response:
content = (
event.delta.text
if hasattr(event, "delta")
and hasattr(event.delta, "text")
and event.delta.text
else None
)
if content is None:
continue
if r"\u" in content:
content = safe_unicode_decode(content.encode("utf-8"))
yield content
except Exception as e:
logger.error(f"Error in stream response: {str(e)}")
raise
finally:
# Release the streaming response and the client. The generator owns
# the client lifetime, so the caller never closes it; finally also
# runs on GeneratorExit (early break) which the except clause above
# cannot catch. AsyncStream exposes close() (not aclose()).
#
# The client close lives in an outer finally so it still runs if
# response.close() is interrupted by CancelledError (a BaseException,
# not caught by `except Exception`) — otherwise task cancellation /
# client disconnect during stream teardown would leak the client.
try:
try:
await response.close()
except Exception as close_error:
logger.warning(f"Failed to close Anthropic stream: {close_error}")
finally:
try:
await anthropic_async_client.close()
except Exception as close_error:
logger.warning(f"Failed to close Anthropic client: {close_error}")
return stream_response()
# Generic Anthropic completion function
async def anthropic_complete(
prompt: str,
system_prompt: str | None = None,
history_messages: list[dict[str, Any]] | None = None,
enable_cot: bool = False,
**kwargs: Any,
) -> Union[str, AsyncIterator[str]]:
if history_messages is None:
history_messages = []
model_name = kwargs["hashing_kv"].global_config["llm_model_name"]
return await anthropic_complete_if_cache(
model_name,
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
# Claude 3 Opus specific completion
async def claude_3_opus_complete(
prompt: str,
system_prompt: str | None = None,
history_messages: list[dict[str, Any]] | None = None,
enable_cot: bool = False,
**kwargs: Any,
) -> Union[str, AsyncIterator[str]]:
if history_messages is None:
history_messages = []
return await anthropic_complete_if_cache(
"claude-3-opus-20240229",
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
# Claude 3 Sonnet specific completion
async def claude_3_sonnet_complete(
prompt: str,
system_prompt: str | None = None,
history_messages: list[dict[str, Any]] | None = None,
enable_cot: bool = False,
**kwargs: Any,
) -> Union[str, AsyncIterator[str]]:
if history_messages is None:
history_messages = []
return await anthropic_complete_if_cache(
"claude-3-sonnet-20240229",
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
# Claude 3 Haiku specific completion
async def claude_3_haiku_complete(
prompt: str,
system_prompt: str | None = None,
history_messages: list[dict[str, Any]] | None = None,
enable_cot: bool = False,
**kwargs: Any,
) -> Union[str, AsyncIterator[str]]:
if history_messages is None:
history_messages = []
return await anthropic_complete_if_cache(
"claude-3-haiku-20240307",
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
# Backward-compatibility shim: the previous embedding implementation lived in
# this module under the (misleading) name ``anthropic_embed`` even though it
# called Voyage AI under the hood. The real implementation now lives in
# ``lightrag.llm.voyageai.voyageai_embed``. Keep the old name importable for one
# release cycle so downstream users get a clear deprecation warning instead of
# an ImportError. Remove in a future major version.
def anthropic_embed(*args, **kwargs):
"""Deprecated alias for :func:`lightrag.llm.voyageai.voyageai_embed`.
This shim accepts the same arguments as the original ``anthropic_embed``
function (which was always backed by VoyageAI) and forwards them to
:func:`voyageai_embed`. It will be removed in a future release.
"""
warnings.warn(
"lightrag.llm.anthropic.anthropic_embed is deprecated and will be "
"removed in a future release. Import "
"lightrag.llm.voyageai.voyageai_embed instead.",
DeprecationWarning,
stacklevel=2,
)
from lightrag.llm.voyageai import voyageai_embed
return voyageai_embed.func(*args, **kwargs)
+22
View File
@@ -0,0 +1,22 @@
"""
Azure OpenAI compatibility layer.
This module provides backward compatibility by re-exporting Azure OpenAI functions
from the main openai module where the actual implementation resides.
All core logic for both OpenAI and Azure OpenAI now lives in lightrag.llm.openai,
with this module serving as a thin compatibility wrapper for existing code that
imports from lightrag.llm.azure_openai.
"""
from lightrag.llm.openai import (
azure_openai_complete_if_cache,
azure_openai_complete,
azure_openai_embed,
)
__all__ = [
"azure_openai_complete_if_cache",
"azure_openai_complete",
"azure_openai_embed",
]
+609
View File
@@ -0,0 +1,609 @@
import copy
import inspect
import json
import logging
import warnings
import pipmaster as pm # Pipmaster for dynamic library install
if not pm.is_installed("aioboto3"):
pm.install("aioboto3")
import aioboto3
import numpy as np
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from collections.abc import AsyncIterator
from typing import Any, Union
from lightrag.utils import wrap_embedding_func_with_attrs
# Import botocore exceptions for proper exception handling
try:
from botocore.exceptions import (
ClientError,
ConnectionError as BotocoreConnectionError,
ReadTimeoutError,
)
except ImportError:
# If botocore is not installed, define placeholders
ClientError = Exception
BotocoreConnectionError = Exception
ReadTimeoutError = Exception
class BedrockError(Exception):
"""Generic error for issues related to Amazon Bedrock"""
class BedrockRateLimitError(BedrockError):
"""Error for rate limiting and throttling issues"""
class BedrockConnectionError(BedrockError):
"""Error for network and connection issues"""
class BedrockTimeoutError(BedrockError):
"""Error for timeout issues"""
def _normalize_bedrock_endpoint_url(endpoint_url: str | None) -> str | None:
"""Return a usable Bedrock endpoint override or None for SDK defaults."""
if endpoint_url is None:
return None
normalized = endpoint_url.strip()
if not normalized or normalized == "DEFAULT_BEDROCK_ENDPOINT":
return None
return normalized
def _bedrock_client_kwargs(
region: str | None,
endpoint_url: str | None,
aws_access_key_id: str | None = None,
aws_secret_access_key: str | None = None,
aws_session_token: str | None = None,
) -> dict:
"""Build kwargs for aioboto3 ``session.client("bedrock-runtime", ...)``."""
client_kwargs: dict = {"region_name": region}
if endpoint_url is not None:
client_kwargs["endpoint_url"] = endpoint_url
if aws_access_key_id:
client_kwargs["aws_access_key_id"] = aws_access_key_id
if aws_secret_access_key:
client_kwargs["aws_secret_access_key"] = aws_secret_access_key
if aws_session_token:
client_kwargs["aws_session_token"] = aws_session_token
return client_kwargs
def _handle_bedrock_exception(e: Exception, operation: str = "Bedrock API") -> None:
"""Convert AWS Bedrock exceptions to appropriate custom exceptions.
Args:
e: The exception to handle
operation: Description of the operation for error messages
Raises:
BedrockRateLimitError: For rate limiting and throttling issues (retryable)
BedrockConnectionError: For network and server issues (retryable)
BedrockTimeoutError: For timeout issues (retryable)
BedrockError: For validation and other non-retryable errors
"""
error_message = str(e)
# Handle botocore ClientError with specific error codes
if isinstance(e, ClientError):
error_code = e.response.get("Error", {}).get("Code", "")
error_msg = e.response.get("Error", {}).get("Message", error_message)
# Rate limiting and throttling errors (retryable)
if error_code in [
"ThrottlingException",
"ProvisionedThroughputExceededException",
]:
logging.error(f"{operation} rate limit error: {error_msg}")
raise BedrockRateLimitError(f"Rate limit error: {error_msg}")
# Server errors (retryable)
elif error_code in ["ServiceUnavailableException", "InternalServerException"]:
logging.error(f"{operation} connection error: {error_msg}")
raise BedrockConnectionError(f"Service error: {error_msg}")
# Check for 5xx HTTP status codes (retryable)
elif e.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 0) >= 500:
logging.error(f"{operation} server error: {error_msg}")
raise BedrockConnectionError(f"Server error: {error_msg}")
# Validation and other client errors (non-retryable)
else:
logging.error(f"{operation} client error: {error_msg}")
raise BedrockError(f"Client error: {error_msg}")
# Connection errors (retryable)
elif isinstance(e, BotocoreConnectionError):
logging.error(f"{operation} connection error: {error_message}")
raise BedrockConnectionError(f"Connection error: {error_message}")
# Timeout errors (retryable)
elif isinstance(e, (ReadTimeoutError, TimeoutError)):
logging.error(f"{operation} timeout error: {error_message}")
raise BedrockTimeoutError(f"Timeout error: {error_message}")
# Custom Bedrock errors (already properly typed)
elif isinstance(
e,
(
BedrockRateLimitError,
BedrockConnectionError,
BedrockTimeoutError,
BedrockError,
),
):
raise
# Unknown errors (non-retryable)
else:
logging.error(f"{operation} unexpected error: {error_message}")
raise BedrockError(f"Unexpected error: {error_message}")
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=(
retry_if_exception_type(BedrockRateLimitError)
| retry_if_exception_type(BedrockConnectionError)
| retry_if_exception_type(BedrockTimeoutError)
),
)
async def bedrock_complete_if_cache(
model,
prompt,
system_prompt=None,
history_messages=[],
enable_cot: bool = False,
aws_access_key_id=None,
aws_secret_access_key=None,
aws_session_token=None,
aws_region: str | None = None,
api_key: str | None = None,
endpoint_url: str | None = None,
image_inputs: list[Any] | None = None,
**kwargs,
) -> Union[str, AsyncIterator[str]]:
"""Call Amazon Bedrock Converse API with LightRAG-compatible shims.
Structured output note:
- This adapter does not support OpenAI-style ``response_format`` JSON mode.
- If callers pass ``response_format``, it is stripped before the request.
- Deprecated ``keyword_extraction`` and ``entity_extraction`` booleans are
accepted only as compatibility shims; they emit warnings and are ignored.
Authentication note:
- Bedrock does not use LightRAG's generic ``api_key`` fields.
- ``LLM_BINDING_API_KEY`` and ``EMBEDDING_BINDING_API_KEY`` are ignored for
Bedrock.
- To use Bedrock API key / bearer-token auth, set
``AWS_BEARER_TOKEN_BEDROCK`` before starting the process; this is a
process-level AWS SDK setting.
- For role-specific Bedrock LLMs, use explicit SigV4 parameters
(``aws_access_key_id``, ``aws_secret_access_key``, ``aws_session_token``,
``aws_region``). Per-role bearer-token overrides are not supported.
Endpoint note:
- ``endpoint_url`` overrides the default regional Bedrock endpoint. Pass
``None``, an empty string, or the sentinel ``DEFAULT_BEDROCK_ENDPOINT``
to let the AWS SDK select its default endpoint.
"""
if enable_cot:
logging.debug(
"enable_cot=True is not supported for Bedrock and will be ignored."
)
# Bedrock Converse API has no JSON mode; drop legacy extraction flags and
# response_format below and rely on the prompt template plus downstream
# tolerant JSON parsing.
keyword_extraction = kwargs.pop("keyword_extraction", False)
entity_extraction = kwargs.pop("entity_extraction", False)
if keyword_extraction:
warnings.warn(
"bedrock_complete_if_cache(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
if entity_extraction:
warnings.warn(
"bedrock_complete_if_cache(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
if api_key:
warnings.warn(
"bedrock_complete_if_cache(api_key=...) is ignored; use SigV4 "
"parameters or set AWS_BEARER_TOKEN_BEDROCK before process start.",
DeprecationWarning,
stacklevel=2,
)
region = aws_region or kwargs.pop("aws_region", None)
endpoint_url = _normalize_bedrock_endpoint_url(endpoint_url)
kwargs.pop("hashing_kv", None)
# Capture stream flag (if provided) and remove from kwargs since it's not a Bedrock API parameter
# We'll use this to determine whether to call converse_stream or converse
stream = bool(kwargs.pop("stream", False))
# Remove unsupported args for Bedrock Converse API
for k in [
"response_format",
"tools",
"tool_choice",
"seed",
"presence_penalty",
"frequency_penalty",
"n",
"logprobs",
"top_logprobs",
"max_completion_tokens",
]:
kwargs.pop(k, None)
# Fix message history format
messages = []
for history_message in history_messages:
message = copy.copy(history_message)
message["content"] = [{"text": message["content"]}]
messages.append(message)
# Add user prompt
if image_inputs:
from lightrag.llm._vision_utils import normalize_image_inputs
normalized_images = normalize_image_inputs(image_inputs)
user_content: list[dict[str, Any]] = [{"text": prompt}]
for img in normalized_images:
fmt = img.mime_type.split("/", 1)[1] if "/" in img.mime_type else "png"
user_content.append(
{"image": {"format": fmt, "source": {"bytes": img.raw_bytes}}}
)
messages.append({"role": "user", "content": user_content})
if stream:
logging.getLogger(__name__).debug(
"[bedrock] image_inputs provided; forcing non-stream Converse "
"(stream + image combination has SDK limitations)"
)
stream = False
else:
messages.append({"role": "user", "content": [{"text": prompt}]})
# Initialize Converse API arguments
args = {"modelId": model, "messages": messages}
# Define system prompt
if system_prompt:
args["system"] = [{"text": system_prompt}]
# Map and set up inference parameters
inference_params_map = {
"max_tokens": "maxTokens",
"top_p": "topP",
"stop_sequences": "stopSequences",
}
inference_config: dict[str, Any] = {}
for param in ("max_tokens", "temperature", "top_p", "stop_sequences"):
if param not in kwargs:
continue
value = kwargs.pop(param)
# Bedrock rejects None; a None default means "inherit provider default"
if value is None:
continue
inference_config[inference_params_map.get(param, param)] = value
if inference_config:
args["inferenceConfig"] = inference_config
# Pass-through for model-specific parameters (e.g. Anthropic reasoning_config,
# Nova inferenceConfig extensions). Mirrors OpenAI's `extra_body`.
extra_fields = kwargs.pop("extra_fields", None)
if extra_fields:
args["additionalModelRequestFields"] = extra_fields
# For streaming responses, we need a different approach to keep the connection open
if stream:
# Create a session that will be used throughout the streaming process
session = aioboto3.Session()
client_kwargs = _bedrock_client_kwargs(
region,
endpoint_url,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
aws_session_token=aws_session_token,
)
# Define the generator function that will manage the client lifecycle
async def stream_generator():
# async with ensures the aioboto3 client is closed even under
# task cancellation, avoiding aiohttp "Unclosed connection" warnings.
async with session.client("bedrock-runtime", **client_kwargs) as client:
event_stream = None
try:
# Make the API call
response = await client.converse_stream(**args, **kwargs)
event_stream = response.get("stream")
# Process the stream
async for event in event_stream:
# Validate event structure
if not event or not isinstance(event, dict):
continue
if "contentBlockDelta" in event:
delta = event["contentBlockDelta"].get("delta", {})
text = delta.get("text")
if text:
yield text
# Handle other event types that might indicate stream end
elif "messageStop" in event:
break
except Exception as e:
# Convert to appropriate exception type
_handle_bedrock_exception(e, "Bedrock streaming")
finally:
# Close the event stream once; client cleanup is handled by async with.
# aiobotocore's EventStream exposes sync `close()`, while generic
# async iterators expose async `aclose()` — handle both and dispatch
# awaitable results accordingly.
if event_stream is not None:
close_fn = getattr(event_stream, "close", None) or getattr(
event_stream, "aclose", None
)
if callable(close_fn):
try:
result = close_fn()
if inspect.isawaitable(result):
await result
except Exception as close_error:
logging.warning(
f"Failed to close Bedrock event stream: {close_error}"
)
# Return the generator that manages its own lifecycle
return stream_generator()
# For non-streaming responses, use the standard async context manager pattern
session = aioboto3.Session()
async with session.client(
"bedrock-runtime",
**_bedrock_client_kwargs(
region,
endpoint_url,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
aws_session_token=aws_session_token,
),
) as bedrock_async_client:
try:
# Use converse for non-streaming responses
response = await bedrock_async_client.converse(**args, **kwargs)
# Validate response structure
if (
not response
or "output" not in response
or "message" not in response["output"]
or "content" not in response["output"]["message"]
or not response["output"]["message"]["content"]
):
raise BedrockError("Invalid response structure from Bedrock API")
# When thinking/reasoning is enabled, the first content block is a
# `reasoningContent` block and the visible text follows in a later
# block. Pick the first block that carries a text payload.
content = next(
(
block["text"]
for block in response["output"]["message"]["content"]
if isinstance(block, dict) and block.get("text")
),
None,
)
if not content or content.strip() == "":
raise BedrockError("Received empty content from Bedrock API")
return content
except Exception as e:
# Convert to appropriate exception type
_handle_bedrock_exception(e, "Bedrock converse")
# Generic Bedrock completion function
async def bedrock_complete(
prompt,
system_prompt=None,
history_messages=[],
keyword_extraction=False,
entity_extraction=False,
**kwargs,
) -> Union[str, AsyncIterator[str]]:
# Bedrock Converse API has no JSON mode; the shim booleans are absorbed
# and forwarded so bedrock_complete_if_cache can emit DeprecationWarnings
# with accurate stack frames.
model_name = kwargs["hashing_kv"].global_config["llm_model_name"]
result = await bedrock_complete_if_cache(
model_name,
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
keyword_extraction=keyword_extraction,
entity_extraction=entity_extraction,
**kwargs,
)
return result
@wrap_embedding_func_with_attrs(
embedding_dim=1024, max_token_size=8192, model_name="amazon.titan-embed-text-v2:0"
)
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=(
retry_if_exception_type(BedrockRateLimitError)
| retry_if_exception_type(BedrockConnectionError)
| retry_if_exception_type(BedrockTimeoutError)
),
)
async def bedrock_embed(
texts: list[str],
model: str = "amazon.titan-embed-text-v2:0",
aws_access_key_id=None,
aws_secret_access_key=None,
aws_session_token=None,
aws_region: str | None = None,
api_key: str | None = None,
endpoint_url: str | None = None,
) -> np.ndarray:
"""Generate embeddings with Amazon Bedrock Runtime.
Authentication note:
- Bedrock does not use LightRAG's generic ``api_key`` fields.
- ``LLM_BINDING_API_KEY`` and ``EMBEDDING_BINDING_API_KEY`` are ignored for
Bedrock.
- To use Bedrock API key / bearer-token auth, set
``AWS_BEARER_TOKEN_BEDROCK`` before starting the process; this is a
process-level AWS SDK setting.
- For role-specific Bedrock configuration, use explicit SigV4 parameters
(``aws_access_key_id``, ``aws_secret_access_key``, ``aws_session_token``,
``aws_region``). Per-role bearer-token overrides are not supported.
"""
if api_key:
warnings.warn(
"bedrock_embed(api_key=...) is ignored; use SigV4 parameters or "
"set AWS_BEARER_TOKEN_BEDROCK before process start.",
DeprecationWarning,
stacklevel=2,
)
region = aws_region
endpoint_url = _normalize_bedrock_endpoint_url(endpoint_url)
session = aioboto3.Session()
async with session.client(
"bedrock-runtime",
**_bedrock_client_kwargs(
region,
endpoint_url,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
aws_session_token=aws_session_token,
),
) as bedrock_async_client:
try:
if (model_provider := model.split(".")[0]) == "amazon":
embed_texts = []
for text in texts:
try:
if "v2" in model:
body = json.dumps(
{
"inputText": text,
# 'dimensions': embedding_dim,
"embeddingTypes": ["float"],
}
)
elif "v1" in model:
body = json.dumps({"inputText": text})
else:
raise BedrockError(f"Model {model} is not supported!")
response = await bedrock_async_client.invoke_model(
modelId=model,
body=body,
accept="application/json",
contentType="application/json",
)
response_body = await response.get("body").json()
# Validate response structure
if not response_body or "embedding" not in response_body:
raise BedrockError(
f"Invalid embedding response structure for text: {text[:50]}..."
)
embedding = response_body["embedding"]
if not embedding:
raise BedrockError(
f"Received empty embedding for text: {text[:50]}..."
)
embed_texts.append(embedding)
except Exception as e:
# Convert to appropriate exception type
_handle_bedrock_exception(
e, "Bedrock embedding (amazon, text chunk)"
)
elif model_provider == "cohere":
try:
body = json.dumps(
{
"texts": texts,
"input_type": "search_document",
"truncate": "NONE",
}
)
response = await bedrock_async_client.invoke_model(
modelId=model,
body=body,
accept="application/json",
contentType="application/json",
)
response_body = await response.get("body").json()
# Validate response structure
if not response_body or "embeddings" not in response_body:
raise BedrockError(
"Invalid embedding response structure from Cohere"
)
embeddings = response_body["embeddings"]
if not embeddings or len(embeddings) != len(texts):
raise BedrockError(
f"Invalid embeddings count: expected {len(texts)}, got {len(embeddings) if embeddings else 0}"
)
embed_texts = embeddings
except Exception as e:
# Convert to appropriate exception type
_handle_bedrock_exception(e, "Bedrock embedding (cohere)")
else:
raise BedrockError(
f"Model provider '{model_provider}' is not supported!"
)
# Final validation
if not embed_texts:
raise BedrockError("No embeddings generated")
return np.array(embed_texts)
except Exception as e:
# Convert to appropriate exception type
_handle_bedrock_exception(e, "Bedrock embedding")
+833
View File
@@ -0,0 +1,833 @@
"""
Module that implements containers for specific LLM bindings.
This module provides container implementations for various Large Language Model
bindings and integrations.
"""
from argparse import ArgumentParser, Namespace
import argparse
import json
from dataclasses import asdict, dataclass, field
from typing import Any, ClassVar, List, get_args, get_origin
from lightrag.utils import get_env_value
from lightrag.constants import DEFAULT_TEMPERATURE
def _resolve_optional_type(field_type: Any) -> Any:
"""Return the concrete type for Optional/Union annotations."""
origin = get_origin(field_type)
if origin in (list, dict, tuple):
return field_type
args = get_args(field_type)
if args:
non_none_args = [arg for arg in args if arg is not type(None)]
if len(non_none_args) == 1:
return non_none_args[0]
return field_type
# =============================================================================
# BindingOptions Base Class
# =============================================================================
#
# The BindingOptions class serves as the foundation for all LLM provider bindings
# in LightRAG. It provides a standardized framework for:
#
# 1. Configuration Management:
# - Defines how each LLM provider's configuration parameters are structured
# - Handles default values and type information for each parameter
# - Maps configuration options to command-line arguments and environment variables
#
# 2. Environment Integration:
# - Automatically generates environment variable names from binding parameters
# - Provides methods to create sample .env files for easy configuration
# - Supports configuration via environment variables with fallback to defaults
#
# 3. Command-Line Interface:
# - Dynamically generates command-line arguments for all registered bindings
# - Maintains consistent naming conventions across different LLM providers
# - Provides help text and type validation for each configuration option
#
# 4. Extensibility:
# - Uses class introspection to automatically discover all binding subclasses
# - Requires minimal boilerplate code when adding new LLM provider bindings
# - Maintains separation of concerns between different provider configurations
#
# This design pattern ensures that adding support for a new LLM provider requires
# only defining the provider-specific parameters and help text, while the base
# class handles all the common functionality for argument parsing, environment
# variable handling, and configuration management.
#
# Instances of a derived class of BindingOptions can be used to store multiple
# runtime configurations of options for a single LLM provider. using the
# asdict() method to convert the options to a dictionary.
#
# =============================================================================
@dataclass
class BindingOptions:
"""Base class for binding options."""
# mandatory name of binding
_binding_name: ClassVar[str]
# optional help message for each option
_help: ClassVar[dict[str, str]]
@staticmethod
def _all_class_vars(klass: type, include_inherited=True) -> dict[str, Any]:
"""Print class variables, optionally including inherited ones"""
if include_inherited:
# Get all class variables from MRO
vars_dict = {}
for base in reversed(klass.__mro__[:-1]): # Exclude 'object'
vars_dict.update(
{
k: v
for k, v in base.__dict__.items()
if (
not k.startswith("_")
and not callable(v)
and not isinstance(v, classmethod)
)
}
)
else:
# Only direct class variables
vars_dict = {
k: v
for k, v in klass.__dict__.items()
if (
not k.startswith("_")
and not callable(v)
and not isinstance(v, classmethod)
)
}
return vars_dict
@classmethod
def add_args(cls, parser: ArgumentParser):
group = parser.add_argument_group(f"{cls._binding_name} binding options")
for arg_item in cls.args_env_name_type_value():
# Handle JSON parsing for list types
if arg_item["type"] is List[str]:
def json_list_parser(value):
try:
parsed = json.loads(value)
if not isinstance(parsed, list):
raise argparse.ArgumentTypeError(
f"Expected JSON array, got {type(parsed).__name__}"
)
return parsed
except json.JSONDecodeError as e:
raise argparse.ArgumentTypeError(f"Invalid JSON: {e}")
# Get environment variable with JSON parsing
env_value = get_env_value(f"{arg_item['env_name']}", argparse.SUPPRESS)
if env_value is not argparse.SUPPRESS:
try:
env_value = json_list_parser(env_value)
except argparse.ArgumentTypeError:
env_value = argparse.SUPPRESS
group.add_argument(
f"--{arg_item['argname']}",
type=json_list_parser,
default=env_value,
help=arg_item["help"],
)
# Handle JSON parsing for dict types
elif arg_item["type"] is dict:
def json_dict_parser(value):
try:
parsed = json.loads(value)
if not isinstance(parsed, dict):
raise argparse.ArgumentTypeError(
f"Expected JSON object, got {type(parsed).__name__}"
)
return parsed
except json.JSONDecodeError as e:
raise argparse.ArgumentTypeError(f"Invalid JSON: {e}")
# Get environment variable with JSON parsing
env_value = get_env_value(f"{arg_item['env_name']}", argparse.SUPPRESS)
if env_value is not argparse.SUPPRESS:
try:
env_value = json_dict_parser(env_value)
except argparse.ArgumentTypeError:
env_value = argparse.SUPPRESS
group.add_argument(
f"--{arg_item['argname']}",
type=json_dict_parser,
default=env_value,
help=arg_item["help"],
)
# Handle boolean types specially to avoid argparse bool() constructor issues
elif arg_item["type"] is bool:
def bool_parser(value):
"""Custom boolean parser that handles string representations correctly"""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "t", "on")
return bool(value)
# Get environment variable with proper type conversion
env_value = get_env_value(
f"{arg_item['env_name']}", argparse.SUPPRESS, bool
)
group.add_argument(
f"--{arg_item['argname']}",
type=bool_parser,
default=env_value,
help=arg_item["help"],
)
else:
resolved_type = arg_item["type"]
if resolved_type is not None:
resolved_type = _resolve_optional_type(resolved_type)
group.add_argument(
f"--{arg_item['argname']}",
type=resolved_type,
default=get_env_value(f"{arg_item['env_name']}", argparse.SUPPRESS),
help=arg_item["help"],
)
@classmethod
def args_env_name_type_value(cls):
import dataclasses
args_prefix = f"{cls._binding_name}".replace("_", "-")
env_var_prefix = f"{cls._binding_name}_".upper()
help = cls._help
# Check if this is a dataclass and use dataclass fields
if dataclasses.is_dataclass(cls):
for field in dataclasses.fields(cls):
# Skip private fields
if field.name.startswith("_"):
continue
# Get default value
if field.default is not dataclasses.MISSING:
default_value = field.default
elif field.default_factory is not dataclasses.MISSING:
default_value = field.default_factory()
else:
default_value = None
argdef = {
"argname": f"{args_prefix}-{field.name}",
"env_name": f"{env_var_prefix}{field.name.upper()}",
"type": _resolve_optional_type(field.type),
"default": default_value,
"help": f"{cls._binding_name} -- " + help.get(field.name, ""),
}
yield argdef
else:
# Fallback to old method for non-dataclass classes
class_vars = {
key: value
for key, value in cls._all_class_vars(cls).items()
if not callable(value) and not key.startswith("_")
}
# Get type hints to properly detect List[str] types
type_hints = {}
for base in cls.__mro__:
if hasattr(base, "__annotations__"):
type_hints.update(base.__annotations__)
for class_var in class_vars:
# Use type hint if available, otherwise fall back to type of value
var_type = type_hints.get(class_var, type(class_vars[class_var]))
argdef = {
"argname": f"{args_prefix}-{class_var}",
"env_name": f"{env_var_prefix}{class_var.upper()}",
"type": var_type,
"default": class_vars[class_var],
"help": f"{cls._binding_name} -- " + help.get(class_var, ""),
}
yield argdef
@classmethod
def generate_dot_env_sample(cls):
"""
Generate a sample .env file for all LightRAG binding options.
This method creates a .env file that includes all the binding options
defined by the subclasses of BindingOptions. It uses the args_env_name_type_value()
method to get the list of all options and their default values.
Returns:
str: A string containing the contents of the sample .env file.
"""
from io import StringIO
sample_top = (
"#" * 80
+ "\n"
+ (
"# Autogenerated .env entries list for LightRAG binding options\n"
"#\n"
"# To generate run:\n"
"# $ python -m lightrag.llm.binding_options\n"
)
+ "#" * 80
+ "\n"
)
sample_bottom = (
("#\n# End of .env entries for LightRAG binding options\n")
+ "#" * 80
+ "\n"
)
sample_stream = StringIO()
sample_stream.write(sample_top)
for klass in cls.__subclasses__():
for arg_item in klass.args_env_name_type_value():
if arg_item["help"]:
sample_stream.write(f"# {arg_item['help']}\n")
# Handle JSON formatting for list and dict types
if arg_item["type"] is List[str] or arg_item["type"] is dict:
default_value = json.dumps(arg_item["default"])
else:
default_value = arg_item["default"]
sample_stream.write(f"# {arg_item['env_name']}={default_value}\n\n")
sample_stream.write(sample_bottom)
return sample_stream.getvalue()
@classmethod
def options_dict(cls, args: Namespace) -> dict[str, Any]:
"""
Extract options dictionary for a specific binding from parsed arguments.
This method filters the parsed command-line arguments to return only those
that belong to the specific binding class. It removes the binding prefix
from argument names to create a clean options dictionary.
Args:
args (Namespace): Parsed command-line arguments containing all binding options
Returns:
dict[str, Any]: Dictionary mapping option names (without prefix) to their values
Example:
If args contains {'ollama_num_ctx': 512, 'other_option': 'value'}
and this is called on OllamaOptions, it returns {'num_ctx': 512}
"""
prefix = cls._binding_name + "_"
skipchars = len(prefix)
options = {
key[skipchars:]: value
for key, value in vars(args).items()
if key.startswith(prefix)
}
return options
@classmethod
def options_dict_for_role(
cls, args: Namespace, role: str, is_cross_provider: bool = False
) -> dict[str, Any]:
"""
Extract role-specific provider options with proper inheritance.
Same provider:
- inherit the base binding options from parsed args
- overlay any role-specific environment variable overrides
Cross provider:
- start from empty provider options
- overlay any role-specific environment variable overrides
Role env vars follow the pattern:
`{ROLE}_{BINDING_PREFIX}_{FIELD}`
e.g. `EXTRACT_OPENAI_LLM_TEMPERATURE`
"""
import os
if is_cross_provider:
base: dict[str, Any] = {}
else:
base = cls.options_dict(args)
role_upper = role.upper()
env_prefix = cls._binding_name.upper() + "_"
for arg_item in cls.args_env_name_type_value():
original_env = arg_item["env_name"]
role_env = f"{role_upper}_{original_env}"
field_name = original_env[len(env_prefix) :].lower()
env_raw = os.getenv(role_env)
if env_raw is None:
continue
field_type = _resolve_optional_type(arg_item["type"])
try:
if field_type is bool:
base[field_name] = env_raw.lower() in (
"true",
"1",
"yes",
"t",
"on",
)
elif field_type in (list, List[str]):
base[field_name] = json.loads(env_raw)
elif field_type is dict:
base[field_name] = json.loads(env_raw)
elif field_type is int:
base[field_name] = int(env_raw)
elif field_type is float:
base[field_name] = float(env_raw)
else:
base[field_name] = env_raw
except (ValueError, json.JSONDecodeError):
base[field_name] = env_raw
return base
def asdict(self) -> dict[str, Any]:
"""
Convert an instance of binding options to a dictionary.
This method uses dataclasses.asdict() to convert the dataclass instance
into a dictionary representation, including all its fields and values.
Returns:
dict[str, Any]: Dictionary representation of the binding options instance
"""
return asdict(self)
# =============================================================================
# Binding Options for Ollama
# =============================================================================
#
# Ollama binding options provide configuration for the Ollama local LLM server.
# These options control model behavior, sampling parameters, hardware utilization,
# and performance settings. The parameters are based on Ollama's API specification
# and provide fine-grained control over model inference and generation.
#
# The _OllamaOptionsMixin defines the complete set of available options, while
# OllamaEmbeddingOptions and OllamaLLMOptions provide specialized configurations
# for embedding and language model tasks respectively.
# =============================================================================
@dataclass
class _OllamaOptionsMixin:
"""Options for Ollama bindings."""
# Core context and generation parameters
num_ctx: int = 32768 # Context window size (number of tokens)
num_predict: int = 128 # Maximum number of tokens to predict
num_keep: int = 0 # Number of tokens to keep from the initial prompt
seed: int = -1 # Random seed for generation (-1 for random)
# Sampling parameters
temperature: float = DEFAULT_TEMPERATURE # Controls randomness (0.0-2.0)
top_k: int = 40 # Top-k sampling parameter
top_p: float = 0.9 # Top-p (nucleus) sampling parameter
tfs_z: float = 1.0 # Tail free sampling parameter
typical_p: float = 1.0 # Typical probability mass
min_p: float = 0.0 # Minimum probability threshold
# Repetition control
repeat_last_n: int = 64 # Number of tokens to consider for repetition penalty
repeat_penalty: float = 1.1 # Penalty for repetition
presence_penalty: float = 0.0 # Penalty for token presence
frequency_penalty: float = 0.0 # Penalty for token frequency
# Mirostat sampling
mirostat: int = (
# Mirostat sampling algorithm (0=disabled, 1=Mirostat 1.0, 2=Mirostat 2.0)
0
)
mirostat_tau: float = 5.0 # Mirostat target entropy
mirostat_eta: float = 0.1 # Mirostat learning rate
# Hardware and performance parameters
numa: bool = False # Enable NUMA optimization
num_batch: int = 512 # Batch size for processing
num_gpu: int = -1 # Number of GPUs to use (-1 for auto)
main_gpu: int = 0 # Main GPU index
low_vram: bool = False # Optimize for low VRAM
num_thread: int = 0 # Number of CPU threads (0 for auto)
# Memory and model parameters
f16_kv: bool = True # Use half-precision for key/value cache
logits_all: bool = False # Return logits for all tokens
vocab_only: bool = False # Only load vocabulary
use_mmap: bool = True # Use memory mapping for model files
use_mlock: bool = False # Lock model in memory
embedding_only: bool = False # Only use for embeddings
# Output control
penalize_newline: bool = True # Penalize newline tokens
stop: List[str] = field(default_factory=list) # Stop sequences
# optional help strings
_help: ClassVar[dict[str, str]] = {
"num_ctx": "Context window size (number of tokens)",
"num_predict": "Maximum number of tokens to predict",
"num_keep": "Number of tokens to keep from the initial prompt",
"seed": "Random seed for generation (-1 for random)",
"temperature": "Controls randomness (0.0-2.0, higher = more creative)",
"top_k": "Top-k sampling parameter (0 = disabled)",
"top_p": "Top-p (nucleus) sampling parameter (0.0-1.0)",
"tfs_z": "Tail free sampling parameter (1.0 = disabled)",
"typical_p": "Typical probability mass (1.0 = disabled)",
"min_p": "Minimum probability threshold (0.0 = disabled)",
"repeat_last_n": "Number of tokens to consider for repetition penalty",
"repeat_penalty": "Penalty for repetition (1.0 = no penalty)",
"presence_penalty": "Penalty for token presence (-2.0 to 2.0)",
"frequency_penalty": "Penalty for token frequency (-2.0 to 2.0)",
"mirostat": "Mirostat sampling algorithm (0=disabled, 1=Mirostat 1.0, 2=Mirostat 2.0)",
"mirostat_tau": "Mirostat target entropy",
"mirostat_eta": "Mirostat learning rate",
"numa": "Enable NUMA optimization",
"num_batch": "Batch size for processing",
"num_gpu": "Number of GPUs to use (-1 for auto)",
"main_gpu": "Main GPU index",
"low_vram": "Optimize for low VRAM",
"num_thread": "Number of CPU threads (0 for auto)",
"f16_kv": "Use half-precision for key/value cache",
"logits_all": "Return logits for all tokens",
"vocab_only": "Only load vocabulary",
"use_mmap": "Use memory mapping for model files",
"use_mlock": "Lock model in memory",
"embedding_only": "Only use for embeddings",
"penalize_newline": "Penalize newline tokens",
"stop": 'Stop sequences (JSON array of strings, e.g., \'["</s>", "\\n\\n"]\')',
}
@dataclass
class OllamaEmbeddingOptions(_OllamaOptionsMixin, BindingOptions):
"""Options for Ollama embeddings with specialized configuration for embedding tasks."""
# mandatory name of binding
_binding_name: ClassVar[str] = "ollama_embedding"
@dataclass
class OllamaLLMOptions(_OllamaOptionsMixin, BindingOptions):
"""Options for Ollama LLM with specialized configuration for LLM tasks."""
# mandatory name of binding
_binding_name: ClassVar[str] = "ollama_llm"
# =============================================================================
# Binding Options for Gemini
# =============================================================================
@dataclass
class GeminiLLMOptions(BindingOptions):
"""Options for Google Gemini models."""
_binding_name: ClassVar[str] = "gemini_llm"
temperature: float = DEFAULT_TEMPERATURE
top_p: float = 0.95
top_k: int = 40
max_output_tokens: int | None = None
candidate_count: int = 1
presence_penalty: float = 0.0
frequency_penalty: float = 0.0
stop_sequences: List[str] = field(default_factory=list)
seed: int | None = None
thinking_config: dict | None = None
safety_settings: dict | None = None
_help: ClassVar[dict[str, str]] = {
"temperature": "Controls randomness (0.0-2.0, higher = more creative)",
"top_p": "Nucleus sampling parameter (0.0-1.0)",
"top_k": "Limits sampling to the top K tokens (1 disables the limit)",
"max_output_tokens": "Maximum tokens generated in the response",
"candidate_count": "Number of candidates returned per request",
"presence_penalty": "Penalty for token presence (-2.0 to 2.0)",
"frequency_penalty": "Penalty for token frequency (-2.0 to 2.0)",
"stop_sequences": "Stop sequences (JSON array of strings, e.g., '[\"END\"]')",
"seed": "Random seed for reproducible generation (leave empty for random)",
"thinking_config": "Thinking configuration (JSON dict, e.g., '{\"thinking_budget\": 1024}' or '{\"include_thoughts\": true}')",
"safety_settings": "JSON object with Gemini safety settings overrides",
}
@dataclass
class GeminiEmbeddingOptions(BindingOptions):
"""Options for Google Gemini embedding models."""
_binding_name: ClassVar[str] = "gemini_embedding"
task_type: str | None = None
_help: ClassVar[dict[str, str]] = {
"task_type": "Task type for embedding optimization. If not specified, automatically determined from context (RETRIEVAL_QUERY for queries, RETRIEVAL_DOCUMENT for documents). Supported types: RETRIEVAL_DOCUMENT, RETRIEVAL_QUERY, SEMANTIC_SIMILARITY, CLASSIFICATION, CLUSTERING, CODE_RETRIEVAL_QUERY, QUESTION_ANSWERING, FACT_VERIFICATION",
}
# =============================================================================
# Binding Options for OpenAI
# =============================================================================
#
# OpenAI binding options provide configuration for OpenAI's API and Azure OpenAI.
# These options control model behavior, sampling parameters, and generation settings.
# The parameters are based on OpenAI's API specification and provide fine-grained
# control over model inference and generation.
#
# =============================================================================
@dataclass
class OpenAILLMOptions(BindingOptions):
"""Options for OpenAI LLM with configuration for OpenAI and Azure OpenAI API calls."""
# mandatory name of binding
_binding_name: ClassVar[str] = "openai_llm"
# Sampling and generation parameters
frequency_penalty: float = 0.0 # Penalty for token frequency (-2.0 to 2.0)
max_completion_tokens: int = None # Maximum number of tokens to generate
presence_penalty: float = 0.0 # Penalty for token presence (-2.0 to 2.0)
reasoning_effort: str = "medium" # Reasoning effort level (low, medium, high)
safety_identifier: str = "" # Safety identifier for content filtering
service_tier: str = "" # Service tier for API usage
stop: List[str] = field(default_factory=list) # Stop sequences
temperature: float = DEFAULT_TEMPERATURE # Controls randomness (0.0 to 2.0)
top_p: float = 1.0 # Nucleus sampling parameter (0.0 to 1.0)
max_tokens: int = None # Maximum number of tokens to generate(deprecated, use max_completion_tokens instead)
extra_body: dict = None # Extra body parameters for OpenRouter of vLLM
# Help descriptions
_help: ClassVar[dict[str, str]] = {
"frequency_penalty": "Penalty for token frequency (-2.0 to 2.0, positive values discourage repetition)",
"max_completion_tokens": "Maximum number of tokens to generate (optional, leave empty for model default)",
"presence_penalty": "Penalty for token presence (-2.0 to 2.0, positive values encourage new topics)",
"reasoning_effort": "Reasoning effort level for o1 models (low, medium, high)",
"safety_identifier": "Safety identifier for content filtering (optional)",
"service_tier": "Service tier for API usage (optional)",
"stop": 'Stop sequences (JSON array of strings, e.g., \'["</s>", "\\n\\n"]\')',
"temperature": "Controls randomness (0.0-2.0, higher = more creative)",
"top_p": "Nucleus sampling parameter (0.0-1.0, lower = more focused)",
"max_tokens": "Maximum number of tokens to generate (deprecated, use max_completion_tokens instead)",
"extra_body": 'Extra body parameters for OpenRouter of vLLM (JSON dict, e.g., \'"reasoning": {"reasoning": {"enabled": false}}\')',
}
# =============================================================================
# Binding Options for AWS Bedrock
# =============================================================================
#
# Bedrock binding options map to the subset of the Bedrock Converse API
# inferenceConfig that LightRAG's bedrock driver actually forwards. See
# ``lightrag/llm/bedrock.py`` for the whitelist — any field added here that is
# not in that whitelist will be silently dropped by the driver.
# =============================================================================
@dataclass
class BedrockLLMOptions(BindingOptions):
"""Options for AWS Bedrock LLM (Converse API inferenceConfig)."""
_binding_name: ClassVar[str] = "bedrock_llm"
temperature: float = DEFAULT_TEMPERATURE
max_tokens: int | None = None
top_p: float = 1.0
stop_sequences: List[str] = field(default_factory=list)
extra_fields: dict = None # Converse API additionalModelRequestFields
_help: ClassVar[dict[str, str]] = {
"temperature": "Controls randomness (0.0-1.0 for most Bedrock models)",
"max_tokens": "Maximum tokens generated in the response (leave empty for model default)",
"top_p": "Nucleus sampling parameter (0.0-1.0)",
"stop_sequences": "Stop sequences (JSON array of strings, e.g., '[\"</s>\"]')",
"extra_fields": 'Model-specific request fields forwarded as Converse API additionalModelRequestFields (JSON dict, e.g., \'{"reasoning_config": {"type": "enabled"}}\')',
}
# =============================================================================
# Main Section - For Testing and Sample Generation
# =============================================================================
#
# When run as a script, this module:
# 1. Generates and prints a sample .env file with all binding options
# 2. If "test" argument is provided, demonstrates argument parsing with Ollama binding
#
# Usage:
# python -m lightrag.llm.binding_options # Generate .env sample
# python -m lightrag.llm.binding_options test # Test argument parsing
#
# =============================================================================
if __name__ == "__main__":
import sys
import dotenv
# from io import StringIO
dotenv.load_dotenv(dotenv_path=".env", override=False)
# env_strstream = StringIO(
# ("OLLAMA_LLM_TEMPERATURE=0.1\nOLLAMA_EMBEDDING_TEMPERATURE=0.2\n")
# )
# # Load environment variables from .env file
# dotenv.load_dotenv(stream=env_strstream)
if len(sys.argv) > 1 and sys.argv[1] == "test":
# Add arguments for OllamaEmbeddingOptions, OllamaLLMOptions, and OpenAILLMOptions
parser = ArgumentParser(description="Test binding options")
OllamaEmbeddingOptions.add_args(parser)
OllamaLLMOptions.add_args(parser)
OpenAILLMOptions.add_args(parser)
# Parse arguments test
args = parser.parse_args(
[
"--ollama-embedding-num_ctx",
"1024",
"--ollama-llm-num_ctx",
"2048",
"--openai-llm-temperature",
"0.7",
"--openai-llm-max_completion_tokens",
"1000",
"--openai-llm-stop",
'["</s>", "\\n\\n"]',
"--openai-llm-reasoning",
'{"effort": "high", "max_tokens": 2000, "exclude": false, "enabled": true}',
]
)
print("Final args for LLM and Embedding:")
print(f"{args}\n")
print("Ollama LLM options:")
print(OllamaLLMOptions.options_dict(args))
print("\nOllama Embedding options:")
print(OllamaEmbeddingOptions.options_dict(args))
print("\nOpenAI LLM options:")
print(OpenAILLMOptions.options_dict(args))
# Test creating OpenAI options instance
openai_options = OpenAILLMOptions(
temperature=0.8,
max_completion_tokens=1500,
frequency_penalty=0.1,
presence_penalty=0.2,
stop=["<|end|>", "\n\n"],
)
print("\nOpenAI LLM options instance:")
print(openai_options.asdict())
# Test creating OpenAI options instance with reasoning parameter
openai_options_with_reasoning = OpenAILLMOptions(
temperature=0.9,
max_completion_tokens=2000,
reasoning={
"effort": "medium",
"max_tokens": 1500,
"exclude": True,
"enabled": True,
},
)
print("\nOpenAI LLM options instance with reasoning:")
print(openai_options_with_reasoning.asdict())
# Test dict parsing functionality
print("\n" + "=" * 50)
print("TESTING DICT PARSING FUNCTIONALITY")
print("=" * 50)
# Test valid JSON dict parsing
test_parser = ArgumentParser(description="Test dict parsing")
OpenAILLMOptions.add_args(test_parser)
try:
test_args = test_parser.parse_args(
["--openai-llm-reasoning", '{"effort": "low", "max_tokens": 1000}']
)
print("✓ Valid JSON dict parsing successful:")
print(
f" Parsed reasoning: {OpenAILLMOptions.options_dict(test_args)['reasoning']}"
)
except Exception as e:
print(f"✗ Valid JSON dict parsing failed: {e}")
# Test invalid JSON dict parsing
try:
test_args = test_parser.parse_args(
[
"--openai-llm-reasoning",
'{"effort": "low", "max_tokens": 1000', # Missing closing brace
]
)
print("✗ Invalid JSON should have failed but didn't")
except SystemExit:
print("✓ Invalid JSON dict parsing correctly rejected")
except Exception as e:
print(f"✓ Invalid JSON dict parsing correctly rejected: {e}")
# Test non-dict JSON parsing
try:
test_args = test_parser.parse_args(
[
"--openai-llm-reasoning",
'["not", "a", "dict"]', # Array instead of dict
]
)
print("✗ Non-dict JSON should have failed but didn't")
except SystemExit:
print("✓ Non-dict JSON parsing correctly rejected")
except Exception as e:
print(f"✓ Non-dict JSON parsing correctly rejected: {e}")
print("\n" + "=" * 50)
print("TESTING ENVIRONMENT VARIABLE SUPPORT")
print("=" * 50)
# Test environment variable support for dict
import os
os.environ["OPENAI_LLM_REASONING"] = (
'{"effort": "high", "max_tokens": 3000, "exclude": false}'
)
env_parser = ArgumentParser(description="Test env var dict parsing")
OpenAILLMOptions.add_args(env_parser)
try:
env_args = env_parser.parse_args(
[]
) # No command line args, should use env var
reasoning_from_env = OpenAILLMOptions.options_dict(env_args).get(
"reasoning"
)
if reasoning_from_env:
print("✓ Environment variable dict parsing successful:")
print(f" Parsed reasoning from env: {reasoning_from_env}")
else:
print("✗ Environment variable dict parsing failed: No reasoning found")
except Exception as e:
print(f"✗ Environment variable dict parsing failed: {e}")
finally:
# Clean up environment variable
if "OPENAI_LLM_REASONING" in os.environ:
del os.environ["OPENAI_LLM_REASONING"]
else:
print(BindingOptions.generate_dot_env_sample())
+69
View File
@@ -0,0 +1,69 @@
import sys
if sys.version_info < (3, 9):
pass
else:
pass
import pipmaster as pm # Pipmaster for dynamic library install
# install specific modules
if not pm.is_installed("lmdeploy"):
pm.install("lmdeploy")
from openai import (
APIConnectionError,
RateLimitError,
APITimeoutError,
)
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
import numpy as np
import aiohttp
import base64
import struct
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def siliconcloud_embedding(
texts: list[str],
model: str = "netease-youdao/bce-embedding-base_v1",
base_url: str = "https://api.siliconflow.cn/v1/embeddings",
max_token_size: int = 8192,
api_key: str = None,
) -> np.ndarray:
if api_key and not api_key.startswith("Bearer "):
api_key = "Bearer " + api_key
headers = {"Authorization": api_key, "Content-Type": "application/json"}
truncate_texts = [text[0:max_token_size] for text in texts]
payload = {"model": model, "input": truncate_texts, "encoding_format": "base64"}
base64_strings = []
async with aiohttp.ClientSession() as session:
async with session.post(base_url, headers=headers, json=payload) as response:
content = await response.json()
if "code" in content:
raise ValueError(content)
base64_strings = [item["embedding"] for item in content["data"]]
embeddings = []
for string in base64_strings:
decode_bytes = base64.b64decode(string)
n = len(decode_bytes) // 4
float_array = struct.unpack("<" + "f" * n, decode_bytes)
embeddings.append(float_array)
return np.array(embeddings)
+751
View File
@@ -0,0 +1,751 @@
"""
Gemini LLM binding for LightRAG.
This module provides asynchronous helpers that adapt Google's Gemini models
to the same interface used by the rest of the LightRAG LLM bindings. The
implementation mirrors the OpenAI helpers while relying on the official
``google-genai`` client under the hood.
"""
from __future__ import annotations
import os
import warnings
from collections.abc import AsyncIterator
from functools import lru_cache
from typing import Any
import numpy as np
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.utils import (
logger,
remove_think_tags,
safe_unicode_decode,
wrap_embedding_func_with_attrs,
)
import pipmaster as pm
# Install the Google Gemini client and its dependencies on demand
if not pm.is_installed("google-genai"):
pm.install("google-genai")
if not pm.is_installed("google-api-core"):
pm.install("google-api-core")
from google import genai # type: ignore
from google.genai import types # type: ignore
from google.api_core import exceptions as google_api_exceptions # type: ignore
class InvalidResponseError(Exception):
"""Custom exception class for triggering retry mechanism when Gemini returns empty responses"""
pass
_DEFAULT_GEMINI_BASE_URLS = {
"https://generativelanguage.googleapis.com",
"https://generativelanguage.googleapis.com/",
"https://generativelanguage.googleapis.com/v1beta",
"https://generativelanguage.googleapis.com/v1beta/",
"https://generativelanguage.googleapis.com/v1",
"https://generativelanguage.googleapis.com/v1/",
}
def _normalize_gemini_base_url(base_url: str | None) -> str | None:
"""Treat Google's default Gemini API service roots as SDK defaults."""
if not base_url:
return None
normalized = base_url.strip()
if not normalized or normalized == "DEFAULT_GEMINI_ENDPOINT":
return None
if normalized.rstrip("/") in {
service_root.rstrip("/") for service_root in _DEFAULT_GEMINI_BASE_URLS
}:
return None
return normalized
@lru_cache(maxsize=8)
def _get_gemini_client(
api_key: str, base_url: str | None, timeout: int | None = None
) -> genai.Client:
"""
Create (or fetch cached) Gemini client.
Args:
api_key: Google Gemini API key (not used in Vertex AI mode).
base_url: Optional custom API endpoint.
timeout: Optional request timeout in milliseconds.
Returns:
genai.Client: Configured Gemini client instance.
"""
client_kwargs: dict[str, Any] = {}
normalized_base_url = _normalize_gemini_base_url(base_url)
# Add Vertex AI support
use_vertexai = os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() == "true"
if use_vertexai:
# Vertex AI mode: use project/location, NOT api_key
client_kwargs["vertexai"] = True
project = os.getenv("GOOGLE_CLOUD_PROJECT")
if project:
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
client_kwargs["project"] = project
if location:
client_kwargs["location"] = location
else:
raise ValueError(
"GOOGLE_CLOUD_PROJECT must be set when using Vertex AI mode"
)
else:
# Standard Gemini API mode: use api_key
client_kwargs["api_key"] = api_key
if normalized_base_url is not None or timeout is not None:
try:
http_options_kwargs = {}
if normalized_base_url is not None:
http_options_kwargs["base_url"] = normalized_base_url
if timeout is not None:
http_options_kwargs["timeout"] = timeout
client_kwargs["http_options"] = types.HttpOptions(**http_options_kwargs)
except Exception as e:
logger.error("Failed to apply custom Gemini http_options: %s", e)
raise e
return genai.Client(**client_kwargs)
def _ensure_api_key(api_key: str | None) -> str:
# In Vertex AI mode, API key is not required
use_vertexai = os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() == "true"
if use_vertexai:
# Return empty string for Vertex AI mode (not used)
return ""
key = api_key or os.getenv("LLM_BINDING_API_KEY") or os.getenv("GEMINI_API_KEY")
if not key:
raise ValueError(
"Gemini API key not provided. "
"Set LLM_BINDING_API_KEY or GEMINI_API_KEY in the environment."
)
return key
def _build_generation_config(
base_config: dict[str, Any] | None,
system_prompt: str | None,
response_format: Any | None,
) -> types.GenerateContentConfig | None:
config_data = dict(base_config or {})
if system_prompt:
if config_data.get("system_instruction"):
config_data["system_instruction"] = (
f"{config_data['system_instruction']}\n{system_prompt}"
)
else:
config_data["system_instruction"] = system_prompt
# Translate response_format to Gemini's native generation config fields.
if response_format is not None:
config_data.setdefault("response_mime_type", "application/json")
schema = _normalize_gemini_response_schema(response_format)
if schema is not None and "response_json_schema" not in config_data:
config_data["response_json_schema"] = schema
# Remove entries that are explicitly set to None to avoid type errors
sanitized = {
key: value
for key, value in config_data.items()
if value is not None and value != ""
}
if not sanitized:
return None
return types.GenerateContentConfig(**sanitized)
def _normalize_gemini_response_schema(response_format: Any) -> Any | None:
"""Extract a Gemini-compatible JSON schema from LightRAG/OpenAI inputs."""
if response_format is None:
return None
if isinstance(response_format, dict):
if response_format.get("type") == "json_object":
return None
if response_format.get("type") == "json_schema":
json_schema = response_format.get("json_schema")
if isinstance(json_schema, dict):
schema = json_schema.get("schema")
if isinstance(schema, dict):
return schema
return json_schema
return response_format
return response_format
def _validate_gemini_response_format(response_format: Any | None) -> None:
"""Reject typed structured-output helpers; only dict payloads are supported."""
if response_format is None or isinstance(response_format, dict):
return
raise TypeError(
"gemini_complete_if_cache only supports dict response_format payloads; "
"typed/Pydantic response_format values are not supported."
)
def _format_history_messages(history_messages: list[dict[str, Any]] | None) -> str:
if not history_messages:
return ""
history_lines: list[str] = []
for message in history_messages:
role = message.get("role", "user")
content = message.get("content", "")
history_lines.append(f"[{role}] {content}")
return "\n".join(history_lines)
def _extract_response_text(
response: Any, extract_thoughts: bool = False
) -> tuple[str, str]:
"""
Extract text content from Gemini response, separating regular content from thoughts.
Args:
response: Gemini API response object
extract_thoughts: Whether to extract thought content separately
Returns:
Tuple of (regular_text, thought_text)
"""
candidates = getattr(response, "candidates", None)
if not candidates:
return ("", "")
regular_parts: list[str] = []
thought_parts: list[str] = []
for candidate in candidates:
if not getattr(candidate, "content", None):
continue
# Use 'or []' to handle None values from parts attribute
for part in getattr(candidate.content, "parts", None) or []:
text = getattr(part, "text", None)
if not text:
continue
# Check if this part is thought content using the 'thought' attribute
is_thought = getattr(part, "thought", False)
if is_thought and extract_thoughts:
thought_parts.append(text)
elif not is_thought:
regular_parts.append(text)
return ("\n".join(regular_parts), "\n".join(thought_parts))
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=(
retry_if_exception_type(google_api_exceptions.InternalServerError)
| retry_if_exception_type(google_api_exceptions.ServiceUnavailable)
| retry_if_exception_type(google_api_exceptions.ResourceExhausted)
| retry_if_exception_type(google_api_exceptions.GatewayTimeout)
| retry_if_exception_type(google_api_exceptions.BadGateway)
| retry_if_exception_type(google_api_exceptions.DeadlineExceeded)
| retry_if_exception_type(google_api_exceptions.Aborted)
| retry_if_exception_type(google_api_exceptions.Unknown)
| retry_if_exception_type(InvalidResponseError)
),
)
async def gemini_complete_if_cache(
model: str,
prompt: str,
system_prompt: str | None = None,
history_messages: list[dict[str, Any]] | None = None,
enable_cot: bool = False,
base_url: str | None = None,
api_key: str | None = None,
token_tracker: Any | None = None,
stream: bool | None = None,
response_format: Any | None = None,
keyword_extraction: bool = False,
entity_extraction: bool = False,
generation_config: dict[str, Any] | None = None,
timeout: int | None = None,
image_inputs: list[Any] | None = None,
**_: Any,
) -> str | AsyncIterator[str]:
"""
Complete a prompt using Gemini's API with Chain of Thought (COT) support.
This function supports automatic integration of reasoning content from Gemini models
that provide Chain of Thought capabilities via the thinking_config API feature.
Structured output note:
- This adapter accepts OpenAI-style ``response_format`` and translates it
to Gemini's native generation config fields.
- ``response_format={"type": "json_object"}`` maps to
``response_mime_type="application/json"``.
- Dict-form ``json_schema`` payloads map to
``response_mime_type="application/json"`` plus
``response_json_schema=<schema>``.
- Typed/Pydantic ``response_format`` helpers are rejected explicitly.
- Deprecated ``keyword_extraction`` and ``entity_extraction`` booleans are
compatibility shims; when no explicit ``response_format`` is supplied,
they are mapped to ``{"type": "json_object"}``.
COT Integration:
- When enable_cot=True: Thought content is wrapped in <think>...</think> tags
- When enable_cot=False: Thought content is filtered out, only regular content returned
- Thought content is identified by the 'thought' attribute on response parts
- Requires thinking_config to be enabled in generation_config for API to return thoughts
Args:
model: The Gemini model to use.
prompt: The prompt to complete.
system_prompt: Optional system prompt to include.
history_messages: Optional list of previous messages in the conversation.
api_key: Optional Gemini API key. If None, uses environment variable.
base_url: Optional custom API endpoint.
generation_config: Optional generation configuration dict.
response_format: OpenAI-style structured output control translated to
Gemini generation config. ``{"type": "json_object"}`` maps to
``response_mime_type="application/json"``; dict-form
``json_schema`` payloads map to ``response_json_schema``.
Typed/Pydantic response_format values are rejected.
token_tracker: Optional token usage tracker for monitoring API usage.
stream: Whether to stream the response.
hashing_kv: Storage interface (for interface parity with other bindings).
enable_cot: Whether to include Chain of Thought content in the response.
timeout: Request timeout in seconds (will be converted to milliseconds for Gemini API).
**_: Additional keyword arguments (ignored).
Returns:
The completed text (with COT content if enable_cot=True) or an async iterator
of text chunks if streaming. COT content is wrapped in <think>...</think> tags.
Raises:
RuntimeError: If the response from Gemini is empty.
ValueError: If API key is not provided or configured.
"""
key = _ensure_api_key(api_key)
# Convert timeout from seconds to milliseconds for Gemini API
timeout_ms = timeout * 1000 if timeout else None
client = _get_gemini_client(key, base_url, timeout_ms)
# Deprecation shims: map legacy boolean flags to response_format only when
# an explicit response_format was not supplied.
if response_format is None:
if entity_extraction:
warnings.warn(
"gemini_complete_if_cache(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
response_format = {"type": "json_object"}
elif keyword_extraction:
warnings.warn(
"gemini_complete_if_cache(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
response_format = {"type": "json_object"}
_validate_gemini_response_format(response_format)
if response_format is not None:
enable_cot = False
history_block = _format_history_messages(history_messages)
prompt_sections = []
if history_block:
prompt_sections.append(history_block)
prompt_sections.append(f"[user] {prompt}")
combined_prompt = "\n".join(prompt_sections)
config_obj = _build_generation_config(
generation_config,
system_prompt=system_prompt,
response_format=response_format,
)
if image_inputs:
from lightrag.llm._vision_utils import normalize_image_inputs
normalized_images = normalize_image_inputs(image_inputs)
parts: list[Any] = [combined_prompt]
parts.extend(
types.Part.from_bytes(data=img.raw_bytes, mime_type=img.mime_type)
for img in normalized_images
)
contents: list[Any] = [parts]
else:
contents = [combined_prompt]
request_kwargs: dict[str, Any] = {
"model": model,
"contents": contents,
}
if config_obj is not None:
request_kwargs["config"] = config_obj
if stream:
async def _async_stream() -> AsyncIterator[str]:
# COT state tracking for streaming
cot_active = False
cot_started = False
initial_content_seen = False
usage_metadata = None
try:
# Use native async streaming from genai SDK
# Note: generate_content_stream returns Awaitable[AsyncIterator], need to await first
stream_iter = await client.aio.models.generate_content_stream(
**request_kwargs
)
async for chunk in stream_iter:
usage = getattr(chunk, "usage_metadata", None)
if usage is not None:
usage_metadata = usage
# Extract both regular and thought content
regular_text, thought_text = _extract_response_text(
chunk, extract_thoughts=True
)
if enable_cot:
# Process regular content
if regular_text:
if not initial_content_seen:
initial_content_seen = True
# Close COT section if it was active
if cot_active:
yield "</think>"
cot_active = False
# Process and yield regular content
if "\\u" in regular_text:
regular_text = safe_unicode_decode(
regular_text.encode("utf-8")
)
yield regular_text
# Process thought content
if thought_text:
if not initial_content_seen and not cot_started:
# Start COT section
yield "<think>"
cot_active = True
cot_started = True
# Yield thought content if COT is active
if cot_active:
if "\\u" in thought_text:
thought_text = safe_unicode_decode(
thought_text.encode("utf-8")
)
yield thought_text
else:
# COT disabled - only yield regular content
if regular_text:
if "\\u" in regular_text:
regular_text = safe_unicode_decode(
regular_text.encode("utf-8")
)
yield regular_text
# Ensure COT is properly closed if still active
if cot_active:
yield "</think>"
cot_active = False
except Exception:
# Try to close COT tag before re-raising
if cot_active:
try:
yield "</think>"
except Exception:
pass
raise
finally:
# Track token usage after streaming completes
if token_tracker and usage_metadata:
token_tracker.add_usage(
{
"prompt_tokens": getattr(
usage_metadata, "prompt_token_count", 0
),
"completion_tokens": getattr(
usage_metadata, "candidates_token_count", 0
),
"total_tokens": getattr(
usage_metadata, "total_token_count", 0
),
}
)
return _async_stream()
# Non-streaming: use native async client
response = await client.aio.models.generate_content(**request_kwargs)
# Extract both regular text and thought text
regular_text, thought_text = _extract_response_text(response, extract_thoughts=True)
# Apply COT filtering logic based on enable_cot parameter
if enable_cot:
# Include thought content wrapped in <think> tags
if thought_text and thought_text.strip():
if not regular_text or regular_text.strip() == "":
# Only thought content available
final_text = f"<think>{thought_text}</think>"
else:
# Both content types present: prepend thought to regular content
final_text = f"<think>{thought_text}</think>{regular_text}"
else:
# No thought content, use regular content only
final_text = regular_text or ""
else:
# Filter out thought content, return only regular content
final_text = regular_text or ""
if not final_text:
raise InvalidResponseError("Gemini response did not contain any text content.")
if "\\u" in final_text:
final_text = safe_unicode_decode(final_text.encode("utf-8"))
final_text = remove_think_tags(final_text)
usage = getattr(response, "usage_metadata", None)
if token_tracker and usage:
token_tracker.add_usage(
{
"prompt_tokens": getattr(usage, "prompt_token_count", 0),
"completion_tokens": getattr(usage, "candidates_token_count", 0),
"total_tokens": getattr(usage, "total_token_count", 0),
}
)
logger.debug("Gemini response length: %s", len(final_text))
return final_text
async def gemini_model_complete(
prompt: str,
system_prompt: str | None = None,
history_messages: list[dict[str, Any]] | None = None,
response_format: Any | None = None,
keyword_extraction: bool = False,
entity_extraction: bool = False,
**kwargs: Any,
) -> str | AsyncIterator[str]:
# Accept legacy keyword if passed via kwargs to preserve backwards compat.
entity_extraction = kwargs.pop("entity_extraction", entity_extraction)
hashing_kv = kwargs.get("hashing_kv")
model_name = None
if hashing_kv is not None:
model_name = hashing_kv.global_config.get("llm_model_name")
if model_name is None:
model_name = kwargs.pop("model_name", None)
if model_name is None:
raise ValueError("Gemini model name not provided in configuration.")
return await gemini_complete_if_cache(
model_name,
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
response_format=response_format,
keyword_extraction=keyword_extraction,
entity_extraction=entity_extraction,
**kwargs,
)
@wrap_embedding_func_with_attrs(
embedding_dim=1536,
max_token_size=2048,
model_name="gemini-embedding-001",
supports_asymmetric=True,
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=(
retry_if_exception_type(google_api_exceptions.InternalServerError)
| retry_if_exception_type(google_api_exceptions.ServiceUnavailable)
| retry_if_exception_type(google_api_exceptions.ResourceExhausted)
| retry_if_exception_type(google_api_exceptions.GatewayTimeout)
| retry_if_exception_type(google_api_exceptions.BadGateway)
| retry_if_exception_type(google_api_exceptions.DeadlineExceeded)
| retry_if_exception_type(google_api_exceptions.Aborted)
| retry_if_exception_type(google_api_exceptions.Unknown)
),
)
async def gemini_embed(
texts: list[str],
model: str = "gemini-embedding-001",
base_url: str | None = None,
api_key: str | None = None,
embedding_dim: int | None = None,
max_token_size: int | None = None,
task_type: str | None = None,
timeout: int | None = None,
token_tracker: Any | None = None,
context: str = "document",
) -> np.ndarray:
"""Generate embeddings for a list of texts using Gemini's API.
This function uses Google's Gemini embedding model to generate text embeddings.
It supports dynamic dimension control and automatic normalization for dimensions
less than 3072.
Args:
texts: List of texts to embed.
model: The Gemini embedding model to use. Default is "gemini-embedding-001".
base_url: Optional custom API endpoint.
api_key: Optional Gemini API key. If None, uses environment variables.
embedding_dim: Optional embedding dimension for dynamic dimension reduction.
**IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper.
Do NOT manually pass this parameter when calling the function directly.
The dimension is controlled by the @wrap_embedding_func_with_attrs decorator
or the EMBEDDING_DIM environment variable.
Supported range: 128-3072. Recommended values: 768, 1536, 3072.
max_token_size: Maximum tokens per text. This parameter is automatically
injected by the EmbeddingFunc wrapper when the underlying function
signature supports it (via inspect.signature check). Gemini API will
automatically truncate texts exceeding this limit (autoTruncate=True
by default), so no client-side truncation is needed.
task_type: Task type for embedding optimization. Default is "RETRIEVAL_DOCUMENT".
Supported types: SEMANTIC_SIMILARITY, CLASSIFICATION, CLUSTERING,
RETRIEVAL_DOCUMENT, RETRIEVAL_QUERY, CODE_RETRIEVAL_QUERY,
QUESTION_ANSWERING, FACT_VERIFICATION.
timeout: Request timeout in seconds (will be converted to milliseconds for Gemini API).
token_tracker: Optional token usage tracker for monitoring API usage.
context: The embedding context - "query" for search queries, "document" for indexed content.
**IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper
when supports_asymmetric=True. Default is "document".
Returns:
A numpy array of embeddings, one per input text. For dimensions < 3072,
the embeddings are L2-normalized to ensure optimal semantic similarity performance.
Raises:
ValueError: If API key is not provided or configured.
RuntimeError: If the response from Gemini is invalid or empty.
Note:
- For dimension 3072: Embeddings are already normalized by the API
- For dimensions < 3072: Embeddings are L2-normalized after retrieval
- Normalization ensures accurate semantic similarity via cosine distance
- Gemini API automatically truncates texts exceeding max_token_size (autoTruncate=True)
"""
# Note: max_token_size is received but not used for client-side truncation.
# Gemini API handles truncation automatically with autoTruncate=True (default).
_ = max_token_size # Acknowledge parameter to avoid unused variable warning
key = _ensure_api_key(api_key)
# Convert timeout from seconds to milliseconds for Gemini API
timeout_ms = timeout * 1000 if timeout else None
client = _get_gemini_client(key, base_url, timeout_ms)
# Prepare embedding configuration
config_kwargs: dict[str, Any] = {}
# Add task_type to config
if task_type is None:
if context == "query":
task_type = "RETRIEVAL_QUERY"
elif context == "document":
task_type = "RETRIEVAL_DOCUMENT"
else:
task_type = "RETRIEVAL_DOCUMENT" # Default for backward compatibility
config_kwargs["task_type"] = task_type
# Add output_dimensionality if embedding_dim is provided
if embedding_dim is not None:
config_kwargs["output_dimensionality"] = embedding_dim
# Create config object if we have parameters
config_obj = types.EmbedContentConfig(**config_kwargs) if config_kwargs else None
request_kwargs: dict[str, Any] = {
"model": model,
"contents": texts,
}
if config_obj is not None:
request_kwargs["config"] = config_obj
# Use native async client for embedding
response = await client.aio.models.embed_content(**request_kwargs)
# Extract embeddings from response
if not hasattr(response, "embeddings") or not response.embeddings:
raise RuntimeError("Gemini response did not contain embeddings.")
# Convert embeddings to numpy array
embeddings = np.array(
[np.array(e.values, dtype=np.float32) for e in response.embeddings]
)
# Apply L2 normalization for dimensions < 3072
# The 3072 dimension embedding is already normalized by Gemini API
if embedding_dim and embedding_dim < 3072:
# Normalize each embedding vector to unit length
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
# Avoid division by zero
norms = np.where(norms == 0, 1, norms)
embeddings = embeddings / norms
logger.debug(
f"Applied L2 normalization to {len(embeddings)} embeddings of dimension {embedding_dim}"
)
# Track token usage if tracker is provided
# Note: Gemini embedding API may not provide usage metadata
if token_tracker and hasattr(response, "usage_metadata"):
usage = response.usage_metadata
token_counts = {
"prompt_tokens": getattr(usage, "prompt_token_count", 0),
"total_tokens": getattr(usage, "total_token_count", 0),
}
token_tracker.add_usage(token_counts)
logger.debug(
f"Generated {len(embeddings)} Gemini embeddings with dimension {embeddings.shape[1]}"
)
return embeddings
__all__ = [
"gemini_complete_if_cache",
"gemini_model_complete",
"gemini_embed",
]
+232
View File
@@ -0,0 +1,232 @@
import copy
import os
import warnings
from functools import lru_cache
import pipmaster as pm # Pipmaster for dynamic library install
# install specific modules
if not pm.is_installed("transformers"):
pm.install("transformers")
if not pm.is_installed("torch"):
pm.install("torch")
if not pm.is_installed("numpy"):
pm.install("numpy")
from transformers import AutoTokenizer, AutoModelForCausalLM
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.exceptions import (
APIConnectionError,
RateLimitError,
APITimeoutError,
)
import torch
import numpy as np
from lightrag.utils import wrap_embedding_func_with_attrs
os.environ["TOKENIZERS_PARALLELISM"] = "false"
@lru_cache(maxsize=1)
def initialize_hf_model(model_name):
hf_tokenizer = AutoTokenizer.from_pretrained(
model_name, device_map="auto", trust_remote_code=True
)
hf_model = AutoModelForCausalLM.from_pretrained(
model_name, device_map="auto", trust_remote_code=True
)
if hf_tokenizer.pad_token is None:
hf_tokenizer.pad_token = hf_tokenizer.eos_token
return hf_model, hf_tokenizer
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def hf_model_if_cache(
model,
prompt,
system_prompt=None,
history_messages=[],
enable_cot: bool = False,
**kwargs,
) -> str:
if enable_cot:
from lightrag.utils import logger
logger.debug(
"enable_cot=True is not supported for Hugging Face local models and will be ignored."
)
model_name = model
hf_model, hf_tokenizer = initialize_hf_model(model_name)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.extend(history_messages)
messages.append({"role": "user", "content": prompt})
kwargs.pop("hashing_kv", None)
input_prompt = ""
try:
input_prompt = hf_tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
except Exception:
try:
ori_message = copy.deepcopy(messages)
if messages[0]["role"] == "system":
messages[1]["content"] = (
"<system>"
+ messages[0]["content"]
+ "</system>\n"
+ messages[1]["content"]
)
messages = messages[1:]
input_prompt = hf_tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
except Exception:
len_message = len(ori_message)
for msgid in range(len_message):
input_prompt = (
input_prompt
+ "<"
+ ori_message[msgid]["role"]
+ ">"
+ ori_message[msgid]["content"]
+ "</"
+ ori_message[msgid]["role"]
+ ">\n"
)
input_ids = hf_tokenizer(
input_prompt, return_tensors="pt", padding=True, truncation=True
).to("cuda")
inputs = {k: v.to(hf_model.device) for k, v in input_ids.items()}
output = hf_model.generate(
**input_ids, max_new_tokens=512, num_return_sequences=1, early_stopping=True
)
response_text = hf_tokenizer.decode(
output[0][len(inputs["input_ids"][0]) :], skip_special_tokens=True
)
return response_text
async def hf_model_complete(
prompt,
system_prompt=None,
history_messages=[],
keyword_extraction=False,
entity_extraction=False,
enable_cot: bool = False,
**kwargs,
) -> str:
"""Run local Hugging Face inference with LightRAG-compatible shims.
Structured output note:
- This adapter does not support OpenAI-style ``response_format`` JSON mode.
- If callers pass ``response_format``, it is stripped before generation.
- Deprecated ``keyword_extraction`` and ``entity_extraction`` booleans are
accepted only as compatibility shims; they emit warnings and are ignored.
"""
# HuggingFace local inference has no JSON mode; drop response_format and
# warn when legacy shim flags are set.
if kwargs.pop("keyword_extraction", False) or keyword_extraction:
warnings.warn(
"hf_model_complete(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
if kwargs.pop("entity_extraction", False) or entity_extraction:
warnings.warn(
"hf_model_complete(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs.pop("response_format", None)
model_name = kwargs["hashing_kv"].global_config["llm_model_name"]
result = await hf_model_if_cache(
model_name,
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
return result
@wrap_embedding_func_with_attrs(
embedding_dim=1024,
max_token_size=8192,
model_name="hf_embedding_model",
supports_asymmetric=True,
)
async def hf_embed(
texts: list[str],
tokenizer,
embed_model,
context: str = "document",
query_prefix: str | None = None,
document_prefix: str | None = None,
) -> np.ndarray:
"""Generate embeddings for a list of texts using a Hugging Face model.
Args:
texts (list[str]): List of input texts to embed.
tokenizer: Hugging Face tokenizer.
embed_model: Hugging Face model for generating embeddings.
context (str): Context indicating whether the texts are "query" or "document".
query_prefix (str | None): Optional prefix to add to query texts.
document_prefix (str | None): Optional prefix to add to document texts.
Returns:
np.ndarray: Array of embeddings.
"""
# Detect the appropriate device
if torch.cuda.is_available():
device = next(embed_model.parameters()).device # Use CUDA if available
elif torch.backends.mps.is_available():
device = torch.device("mps") # Use MPS for Apple Silicon
else:
device = torch.device("cpu") # Fallback to CPU
# Move the model to the detected device
embed_model = embed_model.to(device)
# Apply context-based prefixes if provided
if context == "query" and query_prefix:
texts = [query_prefix + text for text in texts]
elif context == "document" and document_prefix:
texts = [document_prefix + text for text in texts]
# Tokenize the input texts and move them to the same device
encoded_texts = tokenizer(
texts, return_tensors="pt", padding=True, truncation=True
).to(device)
# Perform inference
with torch.no_grad():
outputs = embed_model(
input_ids=encoded_texts["input_ids"],
attention_mask=encoded_texts["attention_mask"],
)
embeddings = outputs.last_hidden_state.mean(dim=1)
# Convert embeddings to NumPy
if embeddings.dtype == torch.bfloat16:
return embeddings.detach().to(torch.float32).cpu().numpy()
else:
return embeddings.detach().cpu().numpy()
+183
View File
@@ -0,0 +1,183 @@
import os
import pipmaster as pm # Pipmaster for dynamic library install
# install specific modules
if not pm.is_installed("aiohttp"):
pm.install("aiohttp")
if not pm.is_installed("tenacity"):
pm.install("tenacity")
import numpy as np
import base64
import aiohttp
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.utils import wrap_embedding_func_with_attrs, logger
async def fetch_data(url, headers, data):
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=data) as response:
if response.status != 200:
error_text = await response.text()
# Check if the error response is HTML (common for 502, 503, etc.)
content_type = response.headers.get("content-type", "").lower()
is_html_error = (
error_text.strip().startswith("<!DOCTYPE html>")
or "text/html" in content_type
)
if is_html_error:
# Provide clean, user-friendly error messages for HTML error pages
if response.status == 502:
clean_error = "Bad Gateway (502) - Jina AI service temporarily unavailable. Please try again in a few minutes."
elif response.status == 503:
clean_error = "Service Unavailable (503) - Jina AI service is temporarily overloaded. Please try again later."
elif response.status == 504:
clean_error = "Gateway Timeout (504) - Jina AI service request timed out. Please try again."
else:
clean_error = f"HTTP {response.status} - Jina AI service error. Please try again later."
else:
# Use original error text if it's not HTML
clean_error = error_text
logger.error(f"Jina API error {response.status}: {clean_error}")
raise aiohttp.ClientResponseError(
request_info=response.request_info,
history=response.history,
status=response.status,
message=f"Jina API error: {clean_error}",
)
response_json = await response.json()
data_list = response_json.get("data", [])
return data_list
@wrap_embedding_func_with_attrs(
embedding_dim=2048,
max_token_size=8192,
model_name="jina-embeddings-v4",
supports_asymmetric=True,
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=(
retry_if_exception_type(aiohttp.ClientError)
| retry_if_exception_type(aiohttp.ClientResponseError)
),
)
async def jina_embed(
texts: list[str],
model: str = "jina-embeddings-v4",
embedding_dim: int = 2048,
late_chunking: bool = False,
base_url: str = None,
api_key: str = None,
context: str | None = None,
task: str | None = None,
) -> np.ndarray:
"""Generate embeddings for a list of texts using Jina AI's API.
Args:
texts: List of texts to embed.
model: The Jina embedding model to use (default: jina-embeddings-v4).
Supported models: jina-embeddings-v3, jina-embeddings-v4, etc.
embedding_dim: The embedding dimensions (default: 2048 for jina-embeddings-v4).
**IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper.
Do NOT manually pass this parameter when calling the function directly.
The dimension is controlled by the @wrap_embedding_func_with_attrs decorator.
Manually passing a different value will trigger a warning and be ignored.
When provided (by EmbeddingFunc), it will be passed to the Jina API for dimension reduction.
late_chunking: Whether to use late chunking.
base_url: Optional base URL for the Jina API.
api_key: Optional Jina API key. If None, uses the JINA_API_KEY environment variable.
context: The embedding context - "query" for search queries, "document" for indexed content.
**IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper
when supports_asymmetric=True. When ``task`` is left at its default of None,
``context`` drives the task selection.
task: Embedding task mode. Default is None so that ``context`` (when present)
picks the right Jina task:
- "retrieval.query" for context="query"
- "retrieval.passage" for context="document"
- "text-matching" otherwise (true backward-compatible default)
Any explicit non-None task value overrides context-based selection.
Returns:
A numpy array of embeddings, one per input text.
Raises:
aiohttp.ClientError: If there is a connection error with the Jina API.
aiohttp.ClientResponseError: If the Jina API returns an error response.
"""
if api_key:
os.environ["JINA_API_KEY"] = api_key
if "JINA_API_KEY" not in os.environ:
raise ValueError("JINA_API_KEY environment variable is required")
url = base_url or "https://api.jina.ai/v1/embeddings"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ['JINA_API_KEY']}",
}
# Determine task based on context if not explicitly provided
if task is None:
if context == "query":
task = "retrieval.query"
elif context == "document":
task = "retrieval.passage"
else:
task = "text-matching" # Default for backward compatibility
data = {
"model": model,
"task": task,
"dimensions": embedding_dim,
"embedding_type": "base64",
"input": texts,
}
# Only add optional parameters if they have non-default values
if late_chunking:
data["late_chunking"] = late_chunking
logger.debug(
f"Jina embedding request: {len(texts)} texts, dimensions: {embedding_dim}"
)
try:
data_list = await fetch_data(url, headers, data)
if not data_list:
logger.error("Jina API returned empty data list")
raise ValueError("Jina API returned empty data list")
if len(data_list) != len(texts):
logger.error(
f"Jina API returned {len(data_list)} embeddings for {len(texts)} texts"
)
raise ValueError(
f"Jina API returned {len(data_list)} embeddings for {len(texts)} texts"
)
embeddings = np.array(
[
np.frombuffer(base64.b64decode(dp["embedding"]), dtype=np.float32)
for dp in data_list
]
)
logger.debug(f"Jina embeddings generated: shape {embeddings.shape}")
return embeddings
except Exception as e:
logger.error(f"Jina embedding error: {e}")
raise
+235
View File
@@ -0,0 +1,235 @@
import warnings
import pipmaster as pm
from llama_index.core.llms import (
ChatMessage,
MessageRole,
ChatResponse,
)
from typing import Any, List, Optional
from lightrag.utils import logger
# Install required dependencies
if not pm.is_installed("llama-index"):
pm.install("llama-index")
from llama_index.core.embeddings import BaseEmbedding
from llama_index.core.settings import Settings as LlamaIndexSettings
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.utils import (
wrap_embedding_func_with_attrs,
)
from lightrag.exceptions import (
APIConnectionError,
RateLimitError,
APITimeoutError,
)
import numpy as np
def configure_llama_index(settings: Any = None, **kwargs):
"""
Configure LlamaIndex settings.
Args:
settings: LlamaIndex Settings instance. If None, uses default settings.
**kwargs: Additional settings to override/configure
"""
if settings is None:
settings = LlamaIndexSettings()
# Update settings with any provided kwargs
for key, value in kwargs.items():
if hasattr(settings, key):
setattr(settings, key, value)
else:
logger.warning(f"Unknown LlamaIndex setting: {key}")
# Set as global settings
LlamaIndexSettings.set_global(settings)
return settings
def format_chat_messages(messages):
"""Format chat messages into LlamaIndex format."""
formatted_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
formatted_messages.append(
ChatMessage(role=MessageRole.SYSTEM, content=content)
)
elif role == "assistant":
formatted_messages.append(
ChatMessage(role=MessageRole.ASSISTANT, content=content)
)
elif role == "user":
formatted_messages.append(
ChatMessage(role=MessageRole.USER, content=content)
)
else:
logger.warning(f"Unknown role {role}, treating as user message")
formatted_messages.append(
ChatMessage(role=MessageRole.USER, content=content)
)
return formatted_messages
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def llama_index_complete_if_cache(
model: str,
prompt: str,
system_prompt: Optional[str] = None,
history_messages: List[dict] = [],
enable_cot: bool = False,
chat_kwargs={},
) -> str:
"""Complete the prompt using LlamaIndex."""
if enable_cot:
logger.debug(
"enable_cot=True is not supported for LlamaIndex implementation and will be ignored."
)
try:
# Format messages for chat
formatted_messages = []
# Add system message if provided
if system_prompt:
formatted_messages.append(
ChatMessage(role=MessageRole.SYSTEM, content=system_prompt)
)
# Add history messages
for msg in history_messages:
formatted_messages.append(
ChatMessage(
role=MessageRole.USER
if msg["role"] == "user"
else MessageRole.ASSISTANT,
content=msg["content"],
)
)
# Add current prompt
formatted_messages.append(ChatMessage(role=MessageRole.USER, content=prompt))
response: ChatResponse = await model.achat(
messages=formatted_messages, **chat_kwargs
)
# In newer versions, the response is in message.content
content = response.message.content
return content
except Exception as e:
logger.error(f"Error in llama_index_complete_if_cache: {str(e)}")
raise
async def llama_index_complete(
prompt,
system_prompt=None,
history_messages=None,
enable_cot: bool = False,
keyword_extraction=False,
entity_extraction=False,
settings: Any = None,
**kwargs,
) -> str:
"""
Main completion function for LlamaIndex.
Args:
prompt: Input prompt
system_prompt: Optional system prompt
history_messages: Optional chat history
keyword_extraction: Deprecated compatibility shim. Emits a warning and
is ignored.
entity_extraction: Deprecated compatibility shim. Emits a warning and
is ignored.
settings: Optional LlamaIndex settings
**kwargs: Additional arguments. ``response_format`` is not supported by
this adapter and is stripped before calling LlamaIndex.
Structured output note:
- This adapter does not support OpenAI-style ``response_format`` JSON mode.
- If callers pass ``response_format``, it is stripped before generation.
"""
if history_messages is None:
history_messages = []
# LlamaIndex adapters have no JSON mode; drop response_format and warn
# when legacy boolean shim flags are set.
if kwargs.pop("keyword_extraction", False) or keyword_extraction:
warnings.warn(
"llama_index_complete(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
if kwargs.pop("entity_extraction", False) or entity_extraction:
warnings.warn(
"llama_index_complete(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs.pop("response_format", None)
result = await llama_index_complete_if_cache(
kwargs.get("llm_instance"),
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
return result
@wrap_embedding_func_with_attrs(embedding_dim=1536, max_token_size=8192)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def llama_index_embed(
texts: list[str],
embed_model: BaseEmbedding = None,
settings: Any = None,
**kwargs,
) -> np.ndarray:
"""
Generate embeddings using LlamaIndex
Args:
texts: List of texts to embed
embed_model: LlamaIndex embedding model
settings: Optional LlamaIndex settings
**kwargs: Additional arguments
"""
if settings:
configure_llama_index(settings)
if embed_model is None:
raise ValueError("embed_model must be provided")
# Use _get_text_embeddings for batch processing
embeddings = embed_model._get_text_embeddings(texts)
return np.array(embeddings)
+179
View File
@@ -0,0 +1,179 @@
import warnings
import pipmaster as pm # Pipmaster for dynamic library install
# install specific modules
if not pm.is_installed("lmdeploy"):
pm.install("lmdeploy[all]")
from lightrag.exceptions import (
APIConnectionError,
RateLimitError,
APITimeoutError,
)
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from functools import lru_cache
@lru_cache(maxsize=1)
def initialize_lmdeploy_pipeline(
model,
tp=1,
chat_template=None,
log_level="WARNING",
model_format="hf",
quant_policy=0,
):
from lmdeploy import pipeline, ChatTemplateConfig, TurbomindEngineConfig
lmdeploy_pipe = pipeline(
model_path=model,
backend_config=TurbomindEngineConfig(
tp=tp, model_format=model_format, quant_policy=quant_policy
),
chat_template_config=(
ChatTemplateConfig(model_name=chat_template) if chat_template else None
),
log_level="WARNING",
)
return lmdeploy_pipe
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def lmdeploy_model_if_cache(
model,
prompt,
system_prompt=None,
history_messages=[],
enable_cot: bool = False,
chat_template=None,
model_format="hf",
quant_policy=0,
**kwargs,
) -> str:
"""Run lmdeploy generation with LightRAG-compatible shims.
Structured output note:
- This adapter does not support OpenAI-style ``response_format`` JSON mode.
- If callers pass ``response_format``, it is stripped before generation.
- Deprecated ``keyword_extraction`` and ``entity_extraction`` booleans are
accepted only as compatibility shims; they emit warnings and are ignored.
Args:
model (str): The path to the model.
It could be one of the following options:
- i) A local directory path of a turbomind model which is
converted by `lmdeploy convert` command or download
from ii) and iii).
- ii) The model_id of a lmdeploy-quantized model hosted
inside a model repo on huggingface.co, such as
"InternLM/internlm-chat-20b-4bit",
"lmdeploy/llama2-chat-70b-4bit", etc.
- iii) The model_id of a model hosted inside a model repo
on huggingface.co, such as "internlm/internlm-chat-7b",
"Qwen/Qwen-7B-Chat ", "baichuan-inc/Baichuan2-7B-Chat"
and so on.
chat_template (str): needed when model is a pytorch model on
huggingface.co, such as "internlm-chat-7b",
"Qwen-7B-Chat ", "Baichuan2-7B-Chat" and so on,
and when the model name of local path did not match the original model name in HF.
tp (int): tensor parallel
prompt (Union[str, List[str]]): input texts to be completed.
do_preprocess (bool): whether pre-process the messages. Default to
True, which means chat_template will be applied.
skip_special_tokens (bool): Whether or not to remove special tokens
in the decoding. Default to be True.
do_sample (bool): Whether or not to use sampling, use greedy decoding otherwise.
Default to be False, which means greedy decoding will be applied.
"""
if enable_cot:
from lightrag.utils import logger
logger.debug(
"enable_cot=True is not supported for lmdeploy and will be ignored."
)
try:
import lmdeploy
from lmdeploy import version_info, GenerationConfig
except Exception:
raise ImportError("Please install lmdeploy before initialize lmdeploy backend.")
kwargs.pop("hashing_kv", None)
# lmdeploy has no JSON mode; drop response_format and warn when legacy
# boolean shim flags are set.
if kwargs.pop("keyword_extraction", False):
warnings.warn(
"lmdeploy_model_if_cache(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
if kwargs.pop("entity_extraction", False):
warnings.warn(
"lmdeploy_model_if_cache(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs.pop("response_format", None)
max_new_tokens = kwargs.pop("max_tokens", 512)
tp = kwargs.pop("tp", 1)
skip_special_tokens = kwargs.pop("skip_special_tokens", True)
do_preprocess = kwargs.pop("do_preprocess", True)
do_sample = kwargs.pop("do_sample", False)
gen_params = kwargs
version = version_info
if do_sample is not None and version < (0, 6, 0):
raise RuntimeError(
"`do_sample` parameter is not supported by lmdeploy until "
f"v0.6.0, but currently using lmdeloy {lmdeploy.__version__}"
)
else:
do_sample = True
gen_params.update(do_sample=do_sample)
lmdeploy_pipe = initialize_lmdeploy_pipeline(
model=model,
tp=tp,
chat_template=chat_template,
model_format=model_format,
quant_policy=quant_policy,
log_level="WARNING",
)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.extend(history_messages)
messages.append({"role": "user", "content": prompt})
gen_config = GenerationConfig(
skip_special_tokens=skip_special_tokens,
max_new_tokens=max_new_tokens,
**gen_params,
)
response = ""
async for res in lmdeploy_pipe.generate(
messages,
gen_config=gen_config,
do_preprocess=do_preprocess,
stream_response=False,
session_id=1,
):
response += res.response
return response
+212
View File
@@ -0,0 +1,212 @@
import sys
import warnings
if sys.version_info < (3, 9):
from typing import AsyncIterator
else:
from collections.abc import AsyncIterator
import pipmaster as pm # Pipmaster for dynamic library install
if not pm.is_installed("aiohttp"):
pm.install("aiohttp")
import aiohttp
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.exceptions import (
APIConnectionError,
RateLimitError,
APITimeoutError,
)
from typing import Any, List, Union
import numpy as np
from lightrag.utils import (
wrap_embedding_func_with_attrs,
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def lollms_model_if_cache(
model,
prompt,
system_prompt=None,
history_messages=[],
enable_cot: bool = False,
base_url="http://localhost:9600",
image_inputs: list[Any] | None = None,
**kwargs,
) -> Union[str, AsyncIterator[str]]:
"""Client implementation for lollms generation.
Structured output note:
- This adapter does not support OpenAI-style ``response_format`` JSON mode.
- If callers pass ``response_format``, it is stripped before the request.
- Deprecated ``keyword_extraction`` and ``entity_extraction`` booleans are
accepted only as compatibility shims; they emit warnings and are ignored.
Vision note:
- lollms does not support image inputs. Passing a non-empty
``image_inputs`` raises :class:`NotImplementedError`.
"""
if image_inputs:
raise NotImplementedError(
"lollms binding does not support image_inputs; configure a "
"vision-capable VLM provider (openai/azure_openai/gemini/bedrock/"
"ollama/anthropic) for VLM_LLM_BINDING."
)
if enable_cot:
from lightrag.utils import logger
logger.debug("enable_cot=True is not supported for lollms and will be ignored.")
# lollms has no JSON mode; drop response_format and warn when legacy
# boolean shim flags are set.
if kwargs.pop("keyword_extraction", False):
warnings.warn(
"lollms_model_if_cache(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
if kwargs.pop("entity_extraction", False):
warnings.warn(
"lollms_model_if_cache(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs.pop("response_format", None)
stream = True if kwargs.get("stream") else False
api_key = kwargs.pop("api_key", None)
headers = (
{"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
if api_key
else {"Content-Type": "application/json"}
)
# Extract lollms specific parameters
request_data = {
"prompt": prompt,
"model_name": model,
"personality": kwargs.get("personality", -1),
"n_predict": kwargs.get("n_predict", None),
"stream": stream,
"temperature": kwargs.get("temperature", 1.0),
"top_k": kwargs.get("top_k", 50),
"top_p": kwargs.get("top_p", 0.95),
"repeat_penalty": kwargs.get("repeat_penalty", 0.8),
"repeat_last_n": kwargs.get("repeat_last_n", 40),
"seed": kwargs.get("seed", None),
"n_threads": kwargs.get("n_threads", 8),
}
# Prepare the full prompt including history
full_prompt = ""
if system_prompt:
full_prompt += f"{system_prompt}\n"
for msg in history_messages:
full_prompt += f"{msg['role']}: {msg['content']}\n"
full_prompt += prompt
request_data["prompt"] = full_prompt
timeout = aiohttp.ClientTimeout(total=kwargs.get("timeout", None))
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
if stream:
async def inner():
async with session.post(
f"{base_url}/lollms_generate", json=request_data
) as response:
async for line in response.content:
yield line.decode().strip()
return inner()
else:
async with session.post(
f"{base_url}/lollms_generate", json=request_data
) as response:
return await response.text()
async def lollms_model_complete(
prompt,
system_prompt=None,
history_messages=[],
enable_cot: bool = False,
keyword_extraction=False,
entity_extraction=False,
**kwargs,
) -> Union[str, AsyncIterator[str]]:
"""Complete function for lollms model generation."""
# Forward legacy extraction flags as kwargs so lollms_model_if_cache can
# emit a single DeprecationWarning with the correct stack frame.
if keyword_extraction:
kwargs.setdefault("keyword_extraction", True)
if entity_extraction:
kwargs.setdefault("entity_extraction", True)
model_name = kwargs["hashing_kv"].global_config["llm_model_name"]
return await lollms_model_if_cache(
model_name,
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
@wrap_embedding_func_with_attrs(
embedding_dim=1024, max_token_size=8192, model_name="lollms_embedding_model"
)
async def lollms_embed(
texts: List[str], embed_model=None, base_url="http://localhost:9600", **kwargs
) -> np.ndarray:
"""
Generate embeddings for a list of texts using lollms server.
Args:
texts: List of strings to embed
embed_model: Model name (not used directly as lollms uses configured vectorizer)
base_url: URL of the lollms server
**kwargs: Additional arguments passed to the request
Returns:
np.ndarray: Array of embeddings
"""
api_key = kwargs.pop("api_key", None)
headers = (
{"Content-Type": "application/json", "Authorization": api_key}
if api_key
else {"Content-Type": "application/json"}
)
async with aiohttp.ClientSession(headers=headers) as session:
embeddings = []
for text in texts:
request_data = {"text": text}
async with session.post(
f"{base_url}/lollms_embed",
json=request_data,
) as response:
result = await response.json()
embeddings.append(result["vector"])
return np.array(embeddings)
+72
View File
@@ -0,0 +1,72 @@
import sys
import os
if sys.version_info < (3, 9):
pass
else:
pass
import pipmaster as pm # Pipmaster for dynamic library install
# install specific modules
if not pm.is_installed("openai"):
pm.install("openai")
from openai import (
AsyncOpenAI,
APIConnectionError,
RateLimitError,
APITimeoutError,
)
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.utils import (
wrap_embedding_func_with_attrs,
)
import numpy as np
@wrap_embedding_func_with_attrs(
embedding_dim=2048, max_token_size=8192, model_name="nvidia_embedding_model"
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def nvidia_openai_embed(
texts: list[str],
model: str = "nvidia/llama-3.2-nv-embedqa-1b-v1",
# refer to https://build.nvidia.com/nim?filters=usecase%3Ausecase_text_to_embedding
base_url: str = "https://integrate.api.nvidia.com/v1",
api_key: str = None,
input_type: str = "passage", # query for retrieval, passage for embedding
trunc: str = "NONE", # NONE or START or END
encode: str = "float", # float or base64
) -> np.ndarray:
if api_key:
os.environ["OPENAI_API_KEY"] = api_key
openai_async_client = (
AsyncOpenAI() if base_url is None else AsyncOpenAI(base_url=base_url)
)
# Hold the client in an async-with so its httpx connection pool is
# released on every exit path (success, error, and each @retry attempt),
# instead of leaking one pool per call until GC. Mirrors ``openai_embed``.
async with openai_async_client:
response = await openai_async_client.embeddings.create(
model=model,
input=texts,
encoding_format=encode,
extra_body={"input_type": input_type, "truncate": trunc},
)
return np.array([dp.embedding for dp in response.data])
+334
View File
@@ -0,0 +1,334 @@
from collections.abc import AsyncIterator
import os
import re
import warnings
import pipmaster as pm
# install specific modules
if not pm.is_installed("ollama"):
pm.install("ollama")
import ollama
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.exceptions import (
APIConnectionError,
RateLimitError,
APITimeoutError,
)
from lightrag.api import __api_version__
import numpy as np
from typing import Any, Optional, Union
from lightrag.utils import (
wrap_embedding_func_with_attrs,
logger,
)
_OLLAMA_CLOUD_HOST = "https://ollama.com"
_CLOUD_MODEL_SUFFIX_PATTERN = re.compile(r"(?:-cloud|:cloud)$")
def _coerce_host_for_cloud_model(host: Optional[str], model: object) -> Optional[str]:
if host:
return host
try:
model_name_str = str(model) if model is not None else ""
except (TypeError, ValueError, AttributeError) as e:
logger.warning(f"Failed to convert model to string: {e}, using empty string")
model_name_str = ""
if _CLOUD_MODEL_SUFFIX_PATTERN.search(model_name_str):
logger.debug(
f"Detected cloud model '{model_name_str}', using Ollama Cloud host"
)
return _OLLAMA_CLOUD_HOST
return host
def _normalize_ollama_response_format(kwargs: dict) -> None:
"""Translate OpenAI-style response_format into Ollama's native format field.
Precedence: an explicit ``format`` value (Ollama's native field) wins over
``response_format`` if ``format`` is already set, ``response_format`` is
dropped silently. Otherwise, ``{"type": "json_object"}`` maps to
``format="json"`` and any other payload is passed through unchanged so
callers can supply JSON schemas directly.
"""
response_format = kwargs.pop("response_format", None)
if kwargs.get("format") is not None or response_format is None:
return
if isinstance(response_format, dict):
if response_format.get("type") == "json_object":
kwargs["format"] = "json"
return
if response_format.get("type") == "json_schema":
json_schema = response_format.get("json_schema")
if isinstance(json_schema, dict):
kwargs["format"] = json_schema.get("schema", json_schema)
return
# Fall back to passing through schema-like payloads for native Ollama support.
kwargs["format"] = response_format
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def _ollama_model_if_cache(
model,
prompt,
system_prompt=None,
history_messages=[],
enable_cot: bool = False,
image_inputs: list[Any] | None = None,
**kwargs,
) -> Union[str, AsyncIterator[str]]:
"""Call Ollama chat API with OpenAI-style structured-output compatibility.
Structured output note:
- This adapter accepts OpenAI-style ``response_format`` and translates it
to Ollama's native ``format`` field.
- ``response_format={"type": "json_object"}`` maps to ``format="json"``.
- Deprecated ``keyword_extraction`` and ``entity_extraction`` booleans are
compatibility shims; when no explicit ``response_format`` is supplied,
they are mapped to ``{"type": "json_object"}``.
"""
if enable_cot:
logger.debug("enable_cot=True is not supported for ollama and will be ignored.")
stream = True if kwargs.get("stream") else False
kwargs.pop("max_tokens", None)
# Deprecation shims: map legacy boolean flags to response_format only when
# an explicit response_format was not supplied by the caller.
if kwargs.get("response_format") is None:
if kwargs.pop("entity_extraction", False):
warnings.warn(
"_ollama_model_if_cache(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs["response_format"] = {"type": "json_object"}
elif kwargs.pop("keyword_extraction", False):
warnings.warn(
"_ollama_model_if_cache(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs["response_format"] = {"type": "json_object"}
else:
# response_format was supplied explicitly; drop legacy flags silently.
kwargs.pop("entity_extraction", None)
kwargs.pop("keyword_extraction", None)
_normalize_ollama_response_format(kwargs)
host = kwargs.pop("host", None)
timeout = kwargs.pop("timeout", None)
if timeout == 0:
timeout = None
kwargs.pop("hashing_kv", None)
api_key = kwargs.pop("api_key", None)
# fallback to environment variable when not provided explicitly
if not api_key:
api_key = os.getenv("OLLAMA_API_KEY")
headers = {
"Content-Type": "application/json",
"User-Agent": f"LightRAG/{__api_version__}",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
host = _coerce_host_for_cloud_model(host, model)
ollama_client = ollama.AsyncClient(host=host, timeout=timeout, headers=headers)
try:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.extend(history_messages)
user_message: dict[str, Any] = {"role": "user", "content": prompt}
if image_inputs:
from lightrag.llm._vision_utils import normalize_image_inputs
normalized_images = normalize_image_inputs(image_inputs)
user_message["images"] = [img.base64_str for img in normalized_images]
messages.append(user_message)
response = await ollama_client.chat(model=model, messages=messages, **kwargs)
if stream:
"""cannot cache stream response and process reasoning"""
async def inner():
try:
async for chunk in response:
yield chunk["message"]["content"]
except Exception as e:
logger.error(f"Error in stream response: {str(e)}")
raise
finally:
try:
await ollama_client._client.aclose()
logger.debug("Successfully closed Ollama client for streaming")
except Exception as close_error:
logger.warning(f"Failed to close Ollama client: {close_error}")
return inner()
else:
model_response = response["message"]["content"]
"""
If the model also wraps its thoughts in a specific tag,
this information is not needed for the final
response and can simply be trimmed.
"""
return model_response
except Exception as e:
try:
await ollama_client._client.aclose()
logger.debug("Successfully closed Ollama client after exception")
except Exception as close_error:
logger.warning(
f"Failed to close Ollama client after exception: {close_error}"
)
raise e
finally:
if not stream:
try:
await ollama_client._client.aclose()
logger.debug(
"Successfully closed Ollama client for non-streaming response"
)
except Exception as close_error:
logger.warning(
f"Failed to close Ollama client in finally block: {close_error}"
)
async def ollama_model_complete(
prompt,
system_prompt=None,
history_messages=[],
enable_cot: bool = False,
keyword_extraction=False,
entity_extraction=False,
**kwargs,
) -> Union[str, AsyncIterator[str]]:
# Forward legacy extraction flags as kwargs so _ollama_model_if_cache can
# emit a single DeprecationWarning with the correct stack frame.
if keyword_extraction:
kwargs.setdefault("keyword_extraction", True)
if entity_extraction:
kwargs.setdefault("entity_extraction", True)
model_name = kwargs["hashing_kv"].global_config["llm_model_name"]
return await _ollama_model_if_cache(
model_name,
prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
@wrap_embedding_func_with_attrs(
embedding_dim=1024,
max_token_size=8192,
model_name="bge-m3:latest",
supports_asymmetric=True,
)
async def ollama_embed(
texts: list[str],
embed_model: str = "bge-m3:latest",
max_token_size: int | None = None,
context: str = "document",
query_prefix: str | None = None,
document_prefix: str | None = None,
**kwargs,
) -> np.ndarray:
"""Generate embeddings using Ollama's API.
Args:
texts: List of texts to embed.
embed_model: The Ollama embedding model to use. Default is "bge-m3:latest".
max_token_size: Maximum tokens per text. This parameter is automatically
injected by the EmbeddingFunc wrapper when the underlying function
signature supports it (via inspect.signature check). Ollama will
automatically truncate texts exceeding the model's context length
(num_ctx), so no client-side truncation is needed.
context: The embedding context - "query" for search queries, "document" for indexed content.
**IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper
when supports_asymmetric=True. Default is "document".
query_prefix: Optional prefix to prepend to texts when context="query" (e.g., "search_query: ").
document_prefix: Optional prefix to prepend to texts when context="document" (e.g., "search_document: ").
**kwargs: Additional arguments passed to the Ollama client.
Returns:
A numpy array of embeddings, one per input text.
Note:
- Ollama API automatically truncates texts exceeding the model's context length
- The max_token_size parameter is received but not used for client-side truncation
"""
# Apply context-based prefixes if provided
if context == "query" and query_prefix:
texts = [query_prefix + text for text in texts]
elif context == "document" and document_prefix:
texts = [document_prefix + text for text in texts]
# Note: max_token_size is received but not used for client-side truncation.
# Ollama API handles truncation automatically based on the model's num_ctx setting.
_ = max_token_size # Acknowledge parameter to avoid unused variable warning
api_key = kwargs.pop("api_key", None)
if not api_key:
api_key = os.getenv("OLLAMA_API_KEY")
headers = {
"Content-Type": "application/json",
"User-Agent": f"LightRAG/{__api_version__}",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
host = kwargs.pop("host", None)
timeout = kwargs.pop("timeout", None)
host = _coerce_host_for_cloud_model(host, embed_model)
ollama_client = ollama.AsyncClient(host=host, timeout=timeout, headers=headers)
try:
options = kwargs.pop("options", {})
data = await ollama_client.embed(
model=embed_model, input=texts, options=options
)
return np.array(data["embeddings"])
except Exception as e:
logger.error(f"Error in ollama_embed: {str(e)}")
try:
await ollama_client._client.aclose()
logger.debug("Successfully closed Ollama client after exception in embed")
except Exception as close_error:
logger.warning(
f"Failed to close Ollama client after exception in embed: {close_error}"
)
raise e
finally:
try:
await ollama_client._client.aclose()
logger.debug("Successfully closed Ollama client after embed")
except Exception as close_error:
logger.warning(f"Failed to close Ollama client after embed: {close_error}")
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
import os
import numpy as np
import pipmaster as pm # Pipmaster for dynamic library install
# Add Voyage AI import
if not pm.is_installed("voyageai"):
pm.install("voyageai")
import voyageai
from voyageai.error import (
APIConnectionError,
RateLimitError,
ServerError,
ServiceUnavailableError,
Timeout,
TryAgain,
)
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.utils import wrap_embedding_func_with_attrs, logger
# Custom exceptions for VoyageAI errors
class VoyageAIError(Exception):
"""Generic VoyageAI API error"""
pass
@wrap_embedding_func_with_attrs(
embedding_dim=1024, max_token_size=32000, supports_asymmetric=True
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=retry_if_exception_type(
(
APIConnectionError,
RateLimitError,
ServerError,
ServiceUnavailableError,
Timeout,
TryAgain,
)
),
)
async def voyageai_embed(
texts: list[str],
model: str = "voyage-3",
api_key: str | None = None,
embedding_dim: int | None = None,
input_type: str | None = None,
truncation: bool | None = True,
context: str | None = None,
) -> np.ndarray:
"""Generate embeddings for a list of texts using VoyageAI's API.
Args:
texts: List of texts to embed.
model: The VoyageAI embedding model to use. Options include:
- "voyage-3": General purpose (1024 dims, 32K context)
- "voyage-3-lite": Lightweight (512 dims, 32K context)
- "voyage-3-large": Highest accuracy (1024 dims, 32K context)
- "voyage-code-3": Code optimized (1024 dims, 32K context)
- "voyage-law-2": Legal documents (1024 dims, 16K context)
- "voyage-finance-2": Finance (1024 dims, 32K context)
api_key: Optional VoyageAI API key. If None, falls back to the
``VOYAGE_API_KEY`` environment variable (the name VoyageAI's own
SDK uses), then to ``VOYAGEAI_API_KEY`` for backward compatibility.
embedding_dim: Optional Matryoshka output dimension. Only honored by
models that support dimension reduction (e.g. voyage-3-large);
ignored otherwise. The decorator default is 1024 to match
``voyage-3``; if you select ``voyage-3-lite`` (512 dims) override
``EMBEDDING_DIM`` accordingly so the vector store size matches.
input_type: Optional input type hint for the model. Options:
- "query": For search queries
- "document": For documents to be indexed
- None: Let the model decide (default)
truncation: Whether the API should truncate texts that exceed the model's
token limit. Defaults to True (matches the VoyageAI SDK default).
context: Optional LightRAG embedding context. When ``input_type`` is not
set, "query" maps to ``input_type="query"`` and "document" maps to
``input_type="document"``.
Returns:
A numpy array of embeddings, one per input text.
Raises:
VoyageAIError: If the API call fails or returns invalid data.
"""
if not api_key:
api_key = os.environ.get("VOYAGE_API_KEY") or os.environ.get("VOYAGEAI_API_KEY")
if not api_key:
logger.error(
"VoyageAI API key not provided and neither VOYAGE_API_KEY nor "
"VOYAGEAI_API_KEY environment variable is set"
)
raise ValueError(
"VoyageAI API key is required: pass api_key, or set the "
"VOYAGE_API_KEY (preferred) or VOYAGEAI_API_KEY environment variable"
)
if input_type is None and context in {"query", "document"}:
input_type = context
try:
client = voyageai.AsyncClient(api_key=api_key)
total_chars = sum(len(t) for t in texts)
avg_chars = total_chars / len(texts) if texts else 0
logger.debug(
f"VoyageAI embedding request: {len(texts)} texts, "
f"total_chars={total_chars}, avg_chars={avg_chars:.0f}, model={model}, "
f"input_type={input_type}"
)
# Prepare API call parameters
embed_params = dict(
texts=texts,
model=model,
# Optional parameters -- if None, voyageai client uses defaults
output_dimension=embedding_dim,
truncation=truncation,
input_type=input_type,
)
# Make API call with timing
result = await client.embed(**embed_params)
if not result.embeddings:
err_msg = "VoyageAI API returned empty embeddings"
logger.error(err_msg)
raise VoyageAIError(err_msg)
if len(result.embeddings) != len(texts):
err_msg = f"VoyageAI API returned {len(result.embeddings)} embeddings for {len(texts)} texts"
logger.error(err_msg)
raise VoyageAIError(err_msg)
# Convert to numpy array with timing
embeddings = np.array(result.embeddings, dtype=np.float32)
logger.debug(f"VoyageAI embeddings generated: shape {embeddings.shape}")
return embeddings
except Exception as e:
logger.error(f"VoyageAI embedding error: {e}")
raise
# Optional: a helper function to get available embedding models
def get_available_embedding_models() -> dict[str, dict]:
"""
Returns a dictionary of available Voyage AI embedding models and their properties.
"""
return {
"voyage-3-large": {
"context_length": 32000,
"dimension": 1024,
"description": "Best general-purpose and multilingual",
},
"voyage-3": {
"context_length": 32000,
"dimension": 1024,
"description": "General-purpose and multilingual",
},
"voyage-3-lite": {
"context_length": 32000,
"dimension": 512,
"description": "Optimized for latency and cost",
},
"voyage-code-3": {
"context_length": 32000,
"dimension": 1024,
"description": "Optimized for code",
},
"voyage-finance-2": {
"context_length": 32000,
"dimension": 1024,
"description": "Optimized for finance",
},
"voyage-law-2": {
"context_length": 16000,
"dimension": 1024,
"description": "Optimized for legal",
},
"voyage-multimodal-3": {
"context_length": 32000,
"dimension": 1024,
"description": "Multimodal text and images",
},
}
+256
View File
@@ -0,0 +1,256 @@
import sys
import warnings
from ..utils import verbose_debug
if sys.version_info < (3, 9):
pass
else:
pass
import pipmaster as pm # Pipmaster for dynamic library install
# install specific modules
if not pm.is_installed("zhipuai"):
pm.install("zhipuai")
from openai import (
APIConnectionError,
RateLimitError,
APITimeoutError,
)
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
from lightrag.utils import (
wrap_embedding_func_with_attrs,
logger,
)
import numpy as np
from typing import Union, List, Optional, Dict
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def zhipu_complete_if_cache(
prompt: Union[str, List[Dict[str, str]]],
model: str = "glm-4-flashx", # The most cost/performance balance model in glm-4 series
api_key: Optional[str] = None,
system_prompt: Optional[str] = None,
history_messages: List[Dict[str, str]] = [],
enable_cot: bool = False, # LightRAG output switch: include reasoning_content as <think>...</think>
thinking: Optional[
Dict[str, object]
] = None, # Zhipu request param: use {"type": "enabled"} to enable thinking
**kwargs,
) -> str:
"""Call Zhipu chat completions with optional official thinking support.
Parameter roles:
- `thinking`: forwarded to the Zhipu API as-is. To enable thinking output,
pass a config such as `{"type": "enabled"}`.
- `enable_cot`: LightRAG-only formatting switch. When True and the API
returns `reasoning_content`, it is preserved in the final string as
`<think>...</think>`.
- `response_format`: forwarded as Zhipu's OpenAI-compatible structured
output parameter when supplied by callers.
- Deprecated `keyword_extraction` and `entity_extraction` booleans are
compatibility shims; when no explicit `response_format` is supplied,
they are mapped to `{"type": "json_object"}`.
"""
# dynamically load ZhipuAI
try:
from zhipuai import ZhipuAI
except ImportError:
raise ImportError("Please install zhipuai before initialize zhipuai backend.")
if api_key:
client = ZhipuAI(api_key=api_key)
else:
# please set ZHIPUAI_API_KEY in your environment
# os.environ["ZHIPUAI_API_KEY"]
client = ZhipuAI()
messages = []
if not system_prompt:
system_prompt = "You are a helpful assistant. Note that sensitive words in the content should be replaced with ***"
# Add system prompt if provided
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.extend(history_messages)
messages.append({"role": "user", "content": prompt})
# Add debug logging
logger.debug("===== Query Input to LLM =====")
logger.debug(f"Query: {prompt}")
verbose_debug(f"System prompt: {system_prompt}")
# Deprecation shims: map legacy extraction booleans to response_format only
# when an explicit response_format was not supplied by the caller. The
# legacy path also forces enable_cot=False so reasoning_content cannot
# corrupt the JSON payload expected by callers relying on it.
keyword_extraction = kwargs.pop("keyword_extraction", False)
entity_extraction = kwargs.pop("entity_extraction", False)
if kwargs.get("response_format") is None:
if entity_extraction:
warnings.warn(
"zhipu_complete_if_cache(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs["response_format"] = {"type": "json_object"}
enable_cot = False
elif keyword_extraction:
warnings.warn(
"zhipu_complete_if_cache(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs["response_format"] = {"type": "json_object"}
enable_cot = False
# Structured output and COT are mutually exclusive here because
# reasoning_content would corrupt the JSON payload expected by callers.
if kwargs.get("response_format") is not None:
enable_cot = False
# Remove unsupported kwargs
kwargs = {
k: v
for k, v in kwargs.items()
if k not in ["hashing_kv", "keyword_extraction", "entity_extraction"]
}
# `thinking` is an official Zhipu request field. Example:
# {"type": "enabled"} enables reasoning output on supported models.
if thinking is not None:
kwargs["thinking"] = thinking
response = client.chat.completions.create(model=model, messages=messages, **kwargs)
if not response.choices or response.choices[0].message is None:
return ""
message = response.choices[0].message
content = message.content or ""
reasoning_content = getattr(message, "reasoning_content", "") or ""
if enable_cot and reasoning_content.strip():
if content:
return f"<think>{reasoning_content}</think>{content}"
return f"<think>{reasoning_content}</think>"
return content
async def zhipu_complete(
prompt,
system_prompt=None,
history_messages=[],
keyword_extraction=False,
entity_extraction=False,
enable_cot: bool = False,
**kwargs,
):
"""Zhipu completion wrapper with LightRAG structured-output shims.
Structured output note:
- This adapter accepts OpenAI-style ``response_format`` and forwards it to
Zhipu's compatible chat-completions API.
- Deprecated ``keyword_extraction`` and ``entity_extraction`` booleans are
compatibility shims; when no explicit ``response_format`` is supplied,
they are mapped to ``{"type": "json_object"}``.
"""
# Pop legacy extraction flags from kwargs to avoid passing them downstream.
keyword_extraction = kwargs.pop("keyword_extraction", keyword_extraction)
entity_extraction = kwargs.pop("entity_extraction", entity_extraction)
# Deprecation shims: map legacy boolean flags to response_format only when
# an explicit response_format was not supplied by the caller. The legacy
# path also forces enable_cot=False so that reasoning_content cannot
# corrupt the JSON payload expected by callers that were relying on it.
if kwargs.get("response_format") is None:
if entity_extraction:
warnings.warn(
"zhipu_complete(entity_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs["response_format"] = {"type": "json_object"}
enable_cot = False
elif keyword_extraction:
warnings.warn(
"zhipu_complete(keyword_extraction=True) is deprecated; "
"pass response_format={'type': 'json_object'} instead.",
DeprecationWarning,
stacklevel=2,
)
kwargs["response_format"] = {"type": "json_object"}
enable_cot = False
return await zhipu_complete_if_cache(
prompt=prompt,
system_prompt=system_prompt,
history_messages=history_messages,
enable_cot=enable_cot,
**kwargs,
)
@wrap_embedding_func_with_attrs(
embedding_dim=1024, max_token_size=8192, model_name="embedding-3"
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=4, max=60),
retry=retry_if_exception_type(
(RateLimitError, APIConnectionError, APITimeoutError)
),
)
async def zhipu_embedding(
texts: list[str],
model: str = "embedding-3",
api_key: str = None,
embedding_dim: int | None = None,
**kwargs,
) -> np.ndarray:
# dynamically load ZhipuAI
try:
from zhipuai import ZhipuAI
except ImportError:
raise ImportError("Please install zhipuai before initialize zhipuai backend.")
if api_key:
client = ZhipuAI(api_key=api_key)
else:
# please set ZHIPUAI_API_KEY in your environment
# os.environ["ZHIPUAI_API_KEY"]
client = ZhipuAI()
# Convert single text to list if needed
if isinstance(texts, str):
texts = [texts]
embeddings = []
for text in texts:
try:
request_kwargs = dict(kwargs)
if embedding_dim is not None:
request_kwargs["dimensions"] = embedding_dim
response = client.embeddings.create(
model=model, input=[text], **request_kwargs
)
embeddings.append(response.data[0].embedding)
except Exception as e:
raise Exception(f"Error calling ChatGLM Embedding API: {str(e)}")
return np.array(embeddings)
+579
View File
@@ -0,0 +1,579 @@
"""LLM role registry, configuration types, and runtime mixin.
LightRAG can route different stages of work (entity extraction, keyword
extraction, query, vlm) to distinct LLM bindings. This module owns the
static role registry (:data:`ROLES`), the per-role configuration
(:class:`RoleLLMConfig`), and the :class:`_RoleLLMMixin` that drives the
runtime: builder registration, wrapper rebuilding, hot config updates,
queue cleanup, and queue-status reporting.
"""
from __future__ import annotations
import asyncio
import inspect
from copy import deepcopy
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable, Mapping
from lightrag.utils import (
get_env_value,
logger,
priority_limit_async_func_call,
)
def _optional_env_int(env_key: str) -> int | None:
return get_env_value(env_key, None, int, special_none=True)
@dataclass(frozen=True)
class RoleSpec:
"""Static descriptor for a known LLM role.
Adding a new role anywhere in LightRAG is a single-line edit: append a
``RoleSpec`` to :data:`ROLES`. Every other component (env var loop in
``api/config.py``, queue observability, role config update flow) iterates
this registry rather than hard-coding role names.
"""
name: str
"""Canonical lowercase role key (used in ``role_llm_configs`` dict and CLI/log output)."""
env_prefix: str
"""Uppercase prefix used by the API env-var layer, e.g. ``"EXTRACT"`` for
``EXTRACT_LLM_BINDING`` / ``EXTRACT_MAX_ASYNC_LLM`` / ``EXTRACT_LLM_TIMEOUT``."""
queue_name: str
"""Display name passed to ``priority_limit_async_func_call`` for log lines."""
ROLES: tuple[RoleSpec, ...] = (
RoleSpec("extract", "EXTRACT", "extract LLM func"),
RoleSpec("keyword", "KEYWORD", "keyword LLM func"),
RoleSpec("query", "QUERY", "query LLM func"),
RoleSpec("vlm", "VLM", "vlm LLM func"),
)
ROLE_NAMES: frozenset[str] = frozenset(spec.name for spec in ROLES)
ROLES_BY_NAME: dict[str, RoleSpec] = {spec.name: spec for spec in ROLES}
@dataclass
class RoleLLMConfig:
"""Per-role LLM override accepted at :class:`LightRAG` init time.
Any field left as ``None`` falls back to the corresponding base LLM
setting (``llm_model_func`` / ``llm_model_kwargs`` / ``llm_model_max_async``
/ ``default_llm_timeout``). When ``max_async`` is None at init and the
user did not pass a ``role_llm_configs`` entry for the role, the value is
additionally seeded from ``{ROLE_PREFIX}_MAX_ASYNC_LLM``. ``metadata`` seeds
runtime observability and role-builder context.
"""
func: Callable[..., object] | None = None
kwargs: dict[str, Any] | None = None
max_async: int | None = None
timeout: int | None = None
metadata: dict[str, Any] | None = None
@dataclass
class _RoleLLMState:
"""Runtime state for one role. Internal — not part of the public API."""
raw_func: Callable[..., object]
kwargs: dict[str, Any] | None
max_async: int | None
timeout: int | None
metadata: dict[str, Any] = field(default_factory=dict)
wrapped: Callable[..., object] | None = None
class _RoleLLMMixin:
"""Mixin that owns the role LLM runtime on :class:`LightRAG`.
Mixed into LightRAG only. Relies on attributes that the main class
initializes in ``__post_init__`` (``_role_llm_states``, ``_role_llm_builders``,
``llm_model_func``, ``llm_model_kwargs``, ``llm_model_max_async``,
``default_llm_timeout``, ``embedding_func``, ``rerank_model_func``).
"""
_SECRET_MARKERS = (
"api_key",
"api-key",
"apikey",
"access_key",
"access-key",
"secret",
"token",
"credential",
"password",
"passphrase",
"pwd",
"auth",
"session",
)
@staticmethod
def _normalize_llm_role(role: str) -> str:
normalized = role.strip().lower()
if normalized not in ROLE_NAMES:
raise ValueError(f"Invalid LLM role: {role}")
return normalized
def register_role_llm_builder(
self,
builder: Callable[
[str, dict[str, Any]], tuple[Callable[..., object], dict[str, Any] | None]
],
) -> None:
"""Register a runtime builder used by update_llm_role_config for binding/model updates."""
self._llm_role_builder = builder
def set_role_llm_metadata(self, role: str, **metadata: Any) -> None:
"""Store role metadata used when rebuilding a role-specific LLM function."""
role = self._normalize_llm_role(role)
state = self._role_llm_states[role]
for key, value in metadata.items():
if value is None:
continue
state.metadata[key] = value
@property
def role_llm_funcs(self) -> Mapping[str, Callable[..., object]]:
"""Read-only mapping of role name → wrapped (queue-managed) LLM func."""
return {
name: state.wrapped
for name, state in self._role_llm_states.items()
if state.wrapped is not None
}
@property
def role_llm_kwargs(self) -> Mapping[str, dict[str, Any] | None]:
"""Read-only mapping of role name → effective LLM kwargs (None means inherit base)."""
return {name: state.kwargs for name, state in self._role_llm_states.items()}
def _get_effective_role_llm_kwargs(self, role: str) -> dict[str, Any]:
state = self._role_llm_states[self._normalize_llm_role(role)]
if state.kwargs is not None:
return state.kwargs
if state.metadata.get("is_cross_provider"):
return {}
return self.llm_model_kwargs
def _get_effective_role_llm_timeout(self, role: str) -> int:
state = self._role_llm_states[self._normalize_llm_role(role)]
return state.timeout if state.timeout is not None else self.default_llm_timeout
def _get_effective_role_llm_max_async(self, role: str) -> int:
state = self._role_llm_states[self._normalize_llm_role(role)]
return (
state.max_async if state.max_async is not None else self.llm_model_max_async
)
def _wrap_llm_role_func(
self,
role_name: str,
raw_func: Callable[..., object],
max_async: int,
timeout: int,
model_kwargs: dict[str, Any],
) -> Callable[..., object]:
spec = ROLES_BY_NAME[role_name]
return priority_limit_async_func_call(
max_async,
llm_timeout=timeout,
queue_name=spec.queue_name,
concurrency_group=f"llm:{role_name}",
)(
partial(
raw_func,
hashing_kv=self.llm_response_cache,
**model_kwargs,
)
)
def _rebuild_role_llm_funcs(self) -> None:
"""Wrap each role's raw_func with its own priority queue.
Base ``llm_model_func`` is intentionally NOT wrapped concurrency
for the base function is enforced at the role layer (every code path
that calls an LLM goes through a role wrapper).
"""
for spec in ROLES:
self._rebuild_single_role_llm_func(spec.name)
def _rebuild_single_role_llm_func(self, role: str) -> None:
role = self._normalize_llm_role(role)
state = self._role_llm_states[role]
state.wrapped = self._wrap_llm_role_func(
role,
state.raw_func,
self._get_effective_role_llm_max_async(role),
self._get_effective_role_llm_timeout(role),
self._get_effective_role_llm_kwargs(role),
)
async def _shutdown_llm_wrapper(self, wrapped_func: Callable[..., object]) -> None:
shutdown = getattr(wrapped_func, "shutdown", None)
if callable(shutdown):
await shutdown(graceful=True)
def _schedule_retired_llm_queue_cleanup(
self, wrapped_func: Callable[..., object] | None
) -> None:
if wrapped_func is None or not callable(
getattr(wrapped_func, "shutdown", None)
):
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# The retired wrapper's queue and worker tasks are tied to the
# event loop that first used them. Spinning up a fresh loop via
# asyncio.run would either hang on queue.join() or touch
# primitives bound to a closed loop. Skip cleanup with a warning
# — call aupdate_llm_role_config() from an async context for
# deterministic shutdown.
logger.warning(
"update_llm_role_config: skipping retired LLM queue cleanup "
"because no event loop is running; call aupdate_llm_role_config() "
"from an async context for deterministic shutdown"
)
return
task = loop.create_task(self._shutdown_llm_wrapper(wrapped_func))
self._retired_llm_queue_cleanup_tasks.add(task)
task.add_done_callback(self._finalize_retired_llm_queue_cleanup)
def _finalize_retired_llm_queue_cleanup(self, task: asyncio.Task) -> None:
self._retired_llm_queue_cleanup_tasks.discard(task)
try:
task.result()
except asyncio.CancelledError:
pass
except Exception as e:
logger.warning(f"Retired LLM queue cleanup failed: {e}")
async def wait_for_retired_llm_queues(self) -> None:
"""Wait until all retired role LLM queues have drained and shut down.
Cleanup failures are logged by ``_finalize_retired_llm_queue_cleanup``
and intentionally swallowed here so callers can rely on this method
always returning once every retired wrapper has finished.
"""
while self._retired_llm_queue_cleanup_tasks:
tasks = list(self._retired_llm_queue_cleanup_tasks)
await asyncio.gather(*tasks, return_exceptions=True)
def _apply_llm_role_config_update(
self,
role: str,
*,
model_func: Callable[..., object] | None = None,
model_kwargs: dict[str, Any] | None = None,
max_async: int | None = None,
timeout: int | None = None,
binding: str | None = None,
model: str | None = None,
host: str | None = None,
api_key: str | None = None,
provider_options: dict[str, Any] | None = None,
) -> Callable[..., object] | None:
role = self._normalize_llm_role(role)
state = self._role_llm_states[role]
old_wrapped = state.wrapped
snapshot = _RoleLLMState(
raw_func=state.raw_func,
kwargs=deepcopy(state.kwargs),
max_async=state.max_async,
timeout=state.timeout,
metadata=deepcopy(state.metadata),
wrapped=state.wrapped,
)
try:
if model_func is not None and not callable(model_func):
raise TypeError("model_func must be callable")
if model_kwargs is not None:
state.kwargs = model_kwargs
if max_async is not None:
state.max_async = max_async
if timeout is not None:
state.timeout = timeout
if model_func is not None:
state.raw_func = model_func
metadata_updated = any(
value is not None
for value in (binding, model, host, api_key, provider_options)
)
if binding is not None:
state.metadata["binding"] = binding
if model is not None:
state.metadata["model"] = model
if host is not None:
state.metadata["host"] = host
if api_key is not None:
state.metadata["api_key"] = api_key
if provider_options is not None:
state.metadata["provider_options"] = provider_options
if "base_binding" in state.metadata and "binding" in state.metadata:
state.metadata["is_cross_provider"] = (
state.metadata["binding"] != state.metadata["base_binding"]
)
if metadata_updated:
builder = getattr(self, "_llm_role_builder", None)
if builder is None and model_func is None:
raise ValueError(
"Runtime role builder is not configured; provide model_func or register_role_llm_builder() first"
)
if builder is not None:
built_func, built_kwargs = builder(role, state.metadata)
state.raw_func = built_func
if model_kwargs is None and built_kwargs is not None:
state.kwargs = built_kwargs
self._rebuild_single_role_llm_func(role)
except Exception:
state.raw_func = snapshot.raw_func
state.kwargs = snapshot.kwargs
state.max_async = snapshot.max_async
state.timeout = snapshot.timeout
state.metadata = snapshot.metadata
state.wrapped = snapshot.wrapped
raise
self._log_llm_role_config("updated", role=role)
return old_wrapped
def update_llm_role_config(
self,
role: str,
*,
model_func: Callable[..., object] | None = None,
model_kwargs: dict[str, Any] | None = None,
max_async: int | None = None,
timeout: int | None = None,
binding: str | None = None,
model: str | None = None,
host: str | None = None,
api_key: str | None = None,
provider_options: dict[str, Any] | None = None,
) -> None:
"""
Update a role-specific LLM configuration at runtime.
Supports lightweight updates (kwargs/max_async/timeout/model_func) directly.
For binding/model/host/api_key/provider_options updates, a role builder must
be registered via register_role_llm_builder().
"""
old_wrapped = self._apply_llm_role_config_update(
role,
model_func=model_func,
model_kwargs=model_kwargs,
max_async=max_async,
timeout=timeout,
binding=binding,
model=model,
host=host,
api_key=api_key,
provider_options=provider_options,
)
self._schedule_retired_llm_queue_cleanup(old_wrapped)
async def aupdate_llm_role_config(
self,
role: str,
*,
model_func: Callable[..., object] | None = None,
model_kwargs: dict[str, Any] | None = None,
max_async: int | None = None,
timeout: int | None = None,
binding: str | None = None,
model: str | None = None,
host: str | None = None,
api_key: str | None = None,
provider_options: dict[str, Any] | None = None,
) -> None:
"""Async variant of update_llm_role_config that waits for queue cleanup.
Blocking behavior:
This coroutine awaits a graceful shutdown of the retired role
wrapper's priority queue. The shutdown blocks on
``queue.join()`` until every already-queued LLM call has been
executed (workers always call ``task_done()`` in ``finally``,
so in-flight requests are not cut off).
The wait is bounded by ``max_task_duration`` of the retired
queue, which is computed as ``llm_timeout * 2 + 15`` seconds
(default ``180 * 2 + 15 = 375`` seconds, ~6 min 15 s). When
this bound is reached, the drain times out and the shutdown
falls through to forced cancellation: pending futures are
cancelled, the queue is cleared, workers are stopped. So this
method **never blocks indefinitely**, but with a deep backlog
of slow LLM calls it can take up to that bound to return, and
in-flight calls past the bound will be cancelled.
If you need a non-blocking switch, use the sync
``update_llm_role_config()`` (which schedules cleanup as a
background task) and await ``wait_for_retired_llm_queues()``
separately when you want to confirm the old queue is gone.
"""
old_wrapped = self._apply_llm_role_config_update(
role,
model_func=model_func,
model_kwargs=model_kwargs,
max_async=max_async,
timeout=timeout,
binding=binding,
model=model,
host=host,
api_key=api_key,
provider_options=provider_options,
)
if old_wrapped is not None:
await self._shutdown_llm_wrapper(old_wrapped)
@classmethod
def _is_secret_key(cls, key: str) -> bool:
lowered = key.lower()
return any(marker in lowered for marker in cls._SECRET_MARKERS)
def _scrubbed_llm_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]:
"""Return a deep copy of ``metadata`` with auth-bearing fields removed.
Auth-bearing fields are stripped entirely not masked because a
masked ``"***"`` carries no information for an external consumer
(operators already see ``binding`` / ``host`` to confirm a role is
configured). Stripping makes the invariant simple: anything that
appears in this output is safe to log, cache, ship over the wire.
Components that legitimately need the raw secret (the role builder,
provider clients) read it directly off the private
``_role_llm_states[role].metadata`` dict.
"""
def scrub_value(value: Any) -> Any:
if isinstance(value, Mapping):
return {
key: scrub_value(inner_value)
for key, inner_value in value.items()
if not self._is_secret_key(str(key))
}
if isinstance(value, list):
return [scrub_value(item) for item in value]
if isinstance(value, tuple):
return tuple(scrub_value(item) for item in value)
return deepcopy(value)
return scrub_value(metadata)
def get_llm_role_config(self, role: str | None = None) -> dict[str, Any]:
"""Return effective role LLM runtime configuration (observability snapshot).
Each role entry exposes ``binding`` / ``model`` / ``host`` at the top
level for convenience and again inside ``metadata`` as part of the
full runtime snapshot (which may contain extra builder-specific
keys). Auth-bearing fields (``api_key``, ``aws_secret_access_key``,
``password``, ) are **stripped entirely** from ``metadata`` this
method is intended for ``/health`` / WebUI / audit output and must
never leak credentials. There is no escape hatch; runtime components
that legitimately need the raw value read it from
``_role_llm_states[role].metadata`` directly.
"""
def role_config(role_name: str) -> dict[str, Any]:
state = self._role_llm_states[role_name]
metadata = self._scrubbed_llm_metadata(state.metadata)
return {
"binding": metadata.get("binding"),
"model": metadata.get("model"),
"host": metadata.get("host"),
"is_cross_provider": metadata.get("is_cross_provider", False),
"max_async": self._get_effective_role_llm_max_async(role_name),
"timeout": self._get_effective_role_llm_timeout(role_name),
"has_model_kwargs": state.kwargs is not None,
"metadata": metadata,
}
if role is not None:
return role_config(self._normalize_llm_role(role))
return {spec.name: role_config(spec.name) for spec in ROLES}
def _log_llm_role_config(self, reason: str, role: str | None = None) -> None:
"""Log the sanitized role LLM runtime configuration."""
if role is None:
configs = self.get_llm_role_config()
role_names = [spec.name for spec in ROLES]
logger.info(f"Role LLM Configuration ({reason}):")
else:
normalized_role = self._normalize_llm_role(role)
configs = {normalized_role: self.get_llm_role_config(normalized_role)}
role_names = [normalized_role]
logger.info(f"Role LLM Configuration ({reason}: {normalized_role}):")
for role_name in role_names:
cfg = configs[role_name]
logger.info(
" - %s: %s/%s, host=%s, max_async=%s, timeout=%s",
role_name,
cfg["binding"],
cfg["model"],
cfg["host"],
cfg["max_async"],
cfg["timeout"],
)
async def _queue_status_for_func(
self, func: Callable[..., object] | None
) -> dict[str, Any]:
if func is None:
return {"available": False}
# Prefer the cross-worker aggregated view (sums every gunicorn
# worker's published snapshot; falls back to the local snapshot
# internally on any shared-storage failure, so "available" keeps
# meaning "this wrapper exists", never "aggregation succeeded").
get_stats = getattr(func, "get_aggregated_queue_stats", None)
if not callable(get_stats):
get_stats = getattr(func, "get_queue_stats", None)
if not callable(get_stats):
return {"available": False}
stats = get_stats()
if inspect.isawaitable(stats):
stats = await stats
stats["available"] = True
return stats
async def get_llm_queue_status(self, include_base: bool = True) -> dict[str, Any]:
"""Return queue status for each role's wrapped LLM func.
The base ``llm_model_func`` is no longer queue-wrapped, so it is not
reported here. ``include_base`` is kept for signature compatibility
but has no effect.
"""
del include_base # base is unwrapped — see docstring
result: dict[str, Any] = {}
for spec in ROLES:
state = self._role_llm_states.get(spec.name)
result[spec.name] = await self._queue_status_for_func(
state.wrapped if state else None
)
return result
async def get_embedding_queue_status(self) -> dict[str, Any]:
"""Return queue status for the wrapped embedding function."""
return await self._queue_status_for_func(
self.embedding_func.func if self.embedding_func is not None else None
)
async def get_rerank_queue_status(self) -> dict[str, Any]:
"""Return queue status for the wrapped rerank function."""
return await self._queue_status_for_func(self.rerank_model_func)
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Iterable
# All namespace should not be changed
class NameSpace:
KV_STORE_FULL_DOCS = "full_docs"
KV_STORE_TEXT_CHUNKS = "text_chunks"
KV_STORE_LLM_RESPONSE_CACHE = "llm_response_cache"
KV_STORE_FULL_ENTITIES = "full_entities"
KV_STORE_FULL_RELATIONS = "full_relations"
KV_STORE_ENTITY_CHUNKS = "entity_chunks"
KV_STORE_RELATION_CHUNKS = "relation_chunks"
VECTOR_STORE_ENTITIES = "entities"
VECTOR_STORE_RELATIONSHIPS = "relationships"
VECTOR_STORE_CHUNKS = "chunks"
GRAPH_STORE_CHUNK_ENTITY_RELATION = "chunk_entity_relation"
DOC_STATUS = "doc_status"
def is_namespace(namespace: str, base_namespace: str | Iterable[str]):
if isinstance(base_namespace, str):
return namespace.endswith(base_namespace)
return any(is_namespace(namespace, ns) for ns in base_namespace)
+6003
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
"""Document parsing layer.
Sub-packages and modules:
- ``routing``: parser engine selection and per-file parser directives.
- ``debug``: minimal ``LightRAG`` stand-in for offline parser debugging.
- ``cli``: ``python -m lightrag.parser.cli`` entry point for single-file
parser debugging across all engines.
- ``docx``: native ``.docx`` parser. Additional native format parsers
should live as sibling sub-packages here (e.g. ``parser/pdf/``).
- ``external``: adapters for external parsing services (``mineru``,
``docling``) that post to a remote API and cache the raw bundle.
"""
+217
View File
@@ -0,0 +1,217 @@
"""Shared HTML-table parsing helpers for parser engines.
Pure functions that recover structural facts from an HTML ``<table>`` string
without a heavy dependency: row/column counts (colspan-aware), the verbatim
``<thead>`` substring, table-payload detection, and ``<html>/<body>`` wrapper
stripping. Originally private to the mineru IR builder; lifted into a leaf
module so the native markdown IR builder can reuse the exact same logic
(merged-cell semantics must survive identically across engines).
Leaf module with no parser-layer imports safe for any engine to import.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from html.parser import HTMLParser
from lightrag.utils import logger
@dataclass
class HTMLTableInfo:
num_rows: int = 0
num_cols: int = 0
class _HTMLTableInfoParser(HTMLParser):
"""Count ``<tr>`` rows and their (colspan-aware) column widths.
Used only to recover ``num_rows`` / ``num_cols`` when the engine did not
supply them; the ``<thead>`` header itself is preserved verbatim by
:func:`extract_thead_html`, not reconstructed here.
"""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
# ``col_count`` (sum of colspans) for each completed top-level ``<tr>``.
self.row_col_counts: list[int] = []
self._tr_depth = 0
self._cell_depth = 0
self._row_col_count = 0
self._cell_colspan = 1
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if tag == "tr":
if self._tr_depth == 0:
self._row_col_count = 0
self._tr_depth += 1
return
if tag in {"td", "th"} and self._tr_depth > 0:
if self._cell_depth == 0:
self._cell_colspan = _cell_span(attrs, "colspan")
self._cell_depth += 1
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if tag in {"td", "th"} and self._cell_depth > 0:
self._cell_depth -= 1
if self._cell_depth == 0:
self._row_col_count += self._cell_colspan
self._cell_colspan = 1
return
if tag == "tr" and self._tr_depth > 0:
self._tr_depth -= 1
# ``col_count > 0`` ⇔ the row held at least one cell (colspan ≥ 1),
# so an empty ``<tr></tr>`` is skipped exactly as before.
if self._tr_depth == 0 and self._row_col_count > 0:
self.row_col_counts.append(self._row_col_count)
self._row_col_count = 0
def _cell_span(attrs: list[tuple[str, str | None]], name: str) -> int:
"""Read a ``colspan``/``rowspan`` attribute as an int ``>= 1`` (default 1)."""
for key, value in attrs:
if key.lower() != name:
continue
try:
return max(int(value or "1"), 1)
except ValueError:
return 1
return 1
def extract_html_table_info(html: str) -> HTMLTableInfo:
parser = _HTMLTableInfoParser()
try:
parser.feed(html or "")
parser.close()
except Exception as exc: # pragma: no cover - HTMLParser is forgiving.
logger.debug("[html_table] failed to parse table HTML: %s", exc)
return HTMLTableInfo()
return HTMLTableInfo(
num_rows=len(parser.row_col_counts),
num_cols=max(parser.row_col_counts, default=0),
)
def extract_thead_html(html: str) -> str | None:
"""Return the first top-level ``<thead …>…</thead>`` substring verbatim.
The raw markup is kept so merged-cell semantics (``rowspan`` / ``colspan``)
survive into ``tables.json`` and, later, into every repeated header chunk
of a split table. Returns ``None`` when the table has no ``<thead>`` or the
``<thead>`` carries no visible text (a blank spacer row, which would
otherwise emit empty ``<th>`` headers).
"""
stripped = (html or "").strip()
lower = stripped.lower()
start = find_html_tag(lower, "thead")
if start < 0:
return None
close = lower.find("</thead>", start)
if close < 0:
return None
thead = stripped[start : close + len("</thead>")]
# Blank check: drop a header whose cells hold no non-whitespace text.
if not re.sub(r"<[^>]+>", "", thead).strip():
return None
return thead
def looks_like_html_table_payload(body: str) -> bool:
lower = (body or "").lstrip().lower()
return any(
starts_with_html_tag(lower, tag)
for tag in ("table", "thead", "tbody", "tfoot", "tr", "html", "body")
)
def unwrap_html_table(payload: str) -> str:
"""Strip a ``<html>/<body>`` document wrapper that a table model
sometimes emits, returning the outermost ``<table></table>`` span. Keeps
a single clean ``<table>`` so the writer does not nest tables and the
non-greedy ``TABLE_TAG_RE`` is not truncated at an inner ``</table>``.
Falls back to the stripped payload when no ``<table>`` element exists."""
stripped = (payload or "").strip()
lower = stripped.lower()
start = _find_table_open(lower)
if start < 0:
return stripped
close = lower.rfind("</table>")
if close < start:
return stripped
return stripped[start : close + len("</table>")]
def _find_table_open(lower: str) -> int:
"""First index of a real ``<table`` start tag (not e.g. ``<tablefoo``).
Returns -1 when none is present."""
return find_html_tag(lower, "table")
def find_html_tag(lower: str, tag: str) -> int:
"""First index of a real ``<tag`` start tag (not e.g. ``<tablefoo`` for
``tag="table"``). ``lower`` must already be lower-cased. Returns -1 when
none is present."""
needle = f"<{tag}"
idx = 0
while True:
idx = lower.find(needle, idx)
if idx < 0:
return -1
nxt = idx + len(needle)
if nxt >= len(lower) or lower[nxt] in {" ", "\t", "\r", "\n", ">", "/"}:
return idx
idx = nxt
def starts_with_html_tag(lower: str, tag: str) -> bool:
prefix = f"<{tag}"
if not lower.startswith(prefix):
return False
if len(lower) == len(prefix):
return True
return lower[len(prefix)] in {" ", "\t", "\r", "\n", ">", "/"}
def html_table_inner_body(html: str) -> str:
stripped = (html or "").strip()
lower = stripped.lower()
if not starts_with_html_tag(lower, "table"):
return stripped
open_end = _open_tag_end(stripped)
close_start = lower.rfind("</table>")
if open_end < 0 or close_start <= open_end:
return stripped
return stripped[open_end + 1 : close_start].strip()
def _open_tag_end(html: str) -> int:
"""Index of the ``>`` closing the leading tag, skipping quoted attribute
values so a ``>`` inside an attribute (e.g. ``<table data-x="a>b">``) does
not terminate the tag early. Returns -1 when no closing ``>`` is found."""
quote: str | None = None
for idx, ch in enumerate(html):
if quote is not None:
if ch == quote:
quote = None
elif ch in {'"', "'"}:
quote = ch
elif ch == ">":
return idx
return -1
__all__ = [
"HTMLTableInfo",
"extract_html_table_info",
"extract_thead_html",
"looks_like_html_table_payload",
"unwrap_html_table",
"find_html_tag",
"starts_with_html_tag",
"html_table_inner_body",
]
+72
View File
@@ -0,0 +1,72 @@
"""Shared markdown rendering helpers for parser engines.
Used by the native docx parser and the external (mineru / docling) IR
builders so heading-line rendering stays identical across engines. This is
a leaf module with no heavy imports ``lightrag/parser/__init__.py`` only
carries a docstring so all three engines can import it without risking a
circular dependency.
"""
from __future__ import annotations
import re
# Markdown caps heading levels at 6 (``######``); deeper outline levels are
# clamped to 6 rather than emitting an illegal 7+ run of ``#``.
MAX_HEADING_LEVEL = 6
# A heading line that is ALREADY markdown: 1-6 ``#`` followed by one or more
# spaces. Used to avoid double-prefixing text that an upstream engine emitted
# with its own markdown heading marker (e.g. mineru/docling extracting
# ``# Foo``).
#
# Ambiguity note: this is a heuristic, not a parse. A heading whose text
# genuinely starts with ``#`` plus a space (an author literally writing
# ``"# Note"`` as heading content) is indistinguishable from an
# already-rendered markdown heading and will be treated as the latter. This
# is accepted as a rare edge case in exchange for engine-agnostic dedup.
_MD_HEADING_RE = re.compile(r"^#{1,6} +")
def strip_heading_markdown_prefix(text: str) -> str:
"""Return heading metadata without an existing markdown heading prefix.
The content renderer may keep a source line such as ``"# Foo"`` verbatim
to avoid double-prefixing, but structured metadata (``heading``,
``parent_headings``, doc title) must stay clean. The whole ``#`` run and
all following spaces are removed, so ``"# Extra"`` yields ``"Extra"``
(no leading space leaks into the metadata).
See the module-level ambiguity note: text that genuinely begins with a
``#`` + space run is stripped here too.
"""
return _MD_HEADING_RE.sub("", text, count=1)
def render_heading_line(level: int, text: str) -> str:
"""Render a heading as a markdown-prefixed content line.
Args:
level: 1-based heading level (1 = H1). Values < 1 are treated as 1;
values > :data:`MAX_HEADING_LEVEL` are clamped so a level >= 7
heading still gets ``######``.
text: The heading text.
Returns:
``text`` unchanged when it already starts with a markdown heading
prefix (``^#{1,6} +`` — 1-6 ``#`` then one or more spaces); otherwise
``"#" * clamped_level + " " + text``. See the module-level ambiguity
note: a heading whose content genuinely begins with such a run is
kept verbatim rather than re-prefixed.
"""
if _MD_HEADING_RE.match(text):
return text
hashes = "#" * min(max(level, 1), MAX_HEADING_LEVEL)
return f"{hashes} {text}"
__all__ = [
"MAX_HEADING_LEVEL",
"render_heading_line",
"strip_heading_markdown_prefix",
]
+155
View File
@@ -0,0 +1,155 @@
"""Unified parser contract for native + external parser engines.
Every engine (native docx, mineru, docling, legacy, plus the internal
``reuse``/``passthrough`` format handlers) implements :class:`BaseParser`.
The pipeline dispatches through the registry
(:mod:`lightrag.parser.registry`) instead of a growing ``if engine == ``
chain.
Design notes:
- ``BaseParser`` carries *behaviour only* (the ``parse`` coroutine and any
engine-private hooks). Capability metadata (supported suffixes, queue
group, endpoint requirements) lives in the registry's lightweight
``ParserSpec`` table so capability queries never import a parser
implementation.
- ``ParseResult.to_dict()`` emits only semantically-present fields so the
returned dict stays byte-for-byte compatible with the pre-refactor
``parse_native``/``parse_mineru``/``parse_docling`` return shapes (the
worker reads these by ``.get(...)``).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from lightrag.sidecar.ir import IRDoc # noqa: F401
@dataclass
class ResolvedSource:
"""The common parse preamble shared by every engine."""
source_path: Path
document_name: str
parsed_dir: Path
@dataclass
class ParseContext:
"""Inputs handed to :meth:`BaseParser.parse`.
Wraps the LightRAG instance plus the per-document handles so a parser
does not receive a bare ``self``. Convenience helpers lazily import
pipeline-layer functions at call time, keeping this module import-cheap
and free of import cycles.
"""
rag: Any
doc_id: str
file_path: str
content_data: dict[str, Any]
def source_path(self, parser_engine: str) -> Path:
"""Resolve the on-disk source file for this document."""
from lightrag.pipeline import (
_call_source_file_resolver,
_read_source_file,
)
return Path(
_call_source_file_resolver(
self.rag,
self.file_path,
source_file=_read_source_file(self.content_data),
parser_engine=parser_engine,
)
)
def resolve(self, parser_engine: str) -> ResolvedSource:
"""Resolve ``(source_path, document_name, parsed_dir)``.
Mirrors the preamble shared by ``parse_mineru``/``parse_docling``/
``parse_native``: canonicalize the document name defensively (so
direct callers may pass absolute or hint-bearing paths) and derive
the ``__parsed__/<base>.parsed/`` output directory.
"""
from lightrag.utils_pipeline import (
normalize_document_file_path,
parsed_artifact_dir_for,
)
source_path = self.source_path(parser_engine)
document_name = normalize_document_file_path(self.file_path)
if document_name == "unknown_source":
document_name = source_path.name or f"{self.doc_id}.bin"
parsed_dir = parsed_artifact_dir_for(
document_name, parent_hint=source_path.parent
)
return ResolvedSource(source_path, document_name, parsed_dir)
async def archive_source(self, source_path: str) -> str | None:
"""Archive the source after a successful parse + full_docs sync.
Resolved through the pipeline module's namespace (where the function
is imported) so existing tests that patch
``lightrag.pipeline.archive_docx_source_after_full_docs_sync`` keep
intercepting it now that the call site lives in the parser layer.
"""
import lightrag.pipeline as _pipeline
return await _pipeline.archive_docx_source_after_full_docs_sync(source_path)
@dataclass
class ParseResult:
"""Structured parser output.
``to_dict`` only emits fields that carry meaning so the dict matches the
pre-refactor return shapes exactly (no spurious ``None``/``False`` keys).
"""
doc_id: str
file_path: str
parse_format: str
content: str
blocks_path: str = ""
parse_engine: str | None = None
parse_stage_skipped: bool = False
parse_warnings: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"doc_id": self.doc_id,
"file_path": self.file_path,
"parse_format": self.parse_format,
"content": self.content,
"blocks_path": self.blocks_path,
}
if self.parse_engine is not None:
out["parse_engine"] = self.parse_engine
if self.parse_stage_skipped:
out["parse_stage_skipped"] = True
if self.parse_warnings:
out["parse_warnings"] = self.parse_warnings
return out
class BaseParser(ABC):
"""Abstract base for every parser engine.
Subclasses set ``engine_name`` and implement :meth:`parse`. Capability
metadata (suffixes/queue group/endpoint) is declared in the registry
``ParserSpec``, not here.
"""
engine_name: str
@abstractmethod
async def parse(self, ctx: ParseContext) -> ParseResult:
"""Parse one document and return its :class:`ParseResult`."""
...
+314
View File
@@ -0,0 +1,314 @@
"""Unified sidecar debug CLI for any registered parser engine.
Dispatches one source file through the parser registry
(``get_parser(engine).parse(...)``) and writes the resulting sidecar (and
raw bundle, for external engines) into a flat layout no ``__parsed__/``
middle layer, source file never archived so the artifacts can be
inspected next to the input file. Because dispatch goes through the
registry, a third-party engine registered via ``register_parser`` is a
valid ``--engine`` choice with no CLI changes.
Invocation::
python -m lightrag.parser.cli path/to/sample.docx --engine native
python -m lightrag.parser.cli path/to/sample.pdf --engine mineru
python -m lightrag.parser.cli path/to/sample.pdf --engine docling --force-reparse
See ``docs/ParserDebugCLI-zh.md`` for the full reference.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from contextlib import ExitStack
from pathlib import Path
from typing import Any
from unittest import mock
def _normalize_direct_script_sys_path() -> None:
if __package__:
return
parser_dir = Path(__file__).resolve().parent
repo_root = parser_dir.parent.parent
# Direct execution adds lightrag/parser to sys.path, which makes the
# native parser's third-party ``docx`` import resolve to parser/docx.
sys.path[:] = [
entry for entry in sys.path if Path(entry or ".").resolve() != parser_dir
]
repo_root_str = str(repo_root)
if repo_root_str in sys.path:
sys.path.remove(repo_root_str)
sys.path.insert(0, repo_root_str)
_normalize_direct_script_sys_path()
def _build_parser() -> argparse.ArgumentParser:
# Registry import is cheap by design (no parser impl is pulled in), so
# deriving --engine choices here keeps third-party engines selectable
# without making --help pay for a heavy import.
from lightrag.parser.registry import supported_parser_engines
engines = sorted(supported_parser_engines())
parser = argparse.ArgumentParser(
prog="parse_sidecar",
description=(
"Run a registered parser engine on a single file and emit sidecar "
"artifacts (plus a raw bundle for external engines) into a flat "
"layout alongside the source. No __parsed__/ middle layer; the "
"source file is never moved."
),
)
parser.add_argument("input_file", type=Path, help="Source file to parse.")
parser.add_argument(
"--engine",
required=True,
choices=engines,
help="Parser engine to drive.",
)
parser.add_argument(
"-o",
"--sidecar-parent-dir",
type=Path,
default=None,
help=(
"Parent directory for <name>.parsed/ and <name>.<engine>_raw/. "
"Default: the source file's parent directory."
),
)
parser.add_argument(
"--doc-id",
default=None,
help="Override the doc id. Default: doc-<md5(absolute input path)>.",
)
parser.add_argument(
"--force-reparse",
action="store_true",
help=(
"Only affects mineru/docling. By default a non-empty raw_dir is "
"treated as a valid cache and reused without manifest checks; "
"this flag clears raw_dir and forces a fresh download/parse."
),
)
parser.add_argument(
"--preview",
type=int,
default=5,
metavar="N",
help="Number of block rows to preview after parsing (0 disables).",
)
return parser
def _print_summary(blocks_path: Path, raw_dir: Path | None, preview: int) -> None:
with blocks_path.open("r", encoding="utf-8") as fh:
meta_line = fh.readline().strip()
if not meta_line:
raise SystemExit(f"empty blocks file at {blocks_path}")
meta = json.loads(meta_line)
rows = [json.loads(line) for line in fh if line.strip()]
parsed_dir = blocks_path.parent
print(f"parsed dir : {parsed_dir} (exists={parsed_dir.exists()})")
if raw_dir is not None:
print(f"raw dir : {raw_dir} (exists={raw_dir.exists()})")
print(f"document : {meta.get('document_name')}")
print(f"doc_id : {meta.get('doc_id')}")
print(f"engine : {meta.get('parse_engine')}")
print(f"blocks : {meta.get('blocks')}")
print(
f"sidecars : tables={meta.get('table_file')} "
f"drawings={meta.get('drawing_file')} "
f"equations={meta.get('equation_file')} "
f"asset_dir={meta.get('asset_dir')}"
)
if preview > 0 and rows:
shown = min(preview, len(rows))
print(f"--- preview (first {shown} of {len(rows)} blocks) ---")
for row in rows[:preview]:
heading = row.get("heading") or ""
content = (row.get("content") or "").replace("\n", " ")
snippet = content if len(content) <= 80 else content[:77] + "..."
print(f" [{row.get('blockid', '')[:8]}] heading={heading!r} :: {snippet}")
def _print_raw_summary(result: dict, preview: int) -> None:
"""Summary for engines that emit plain content with no sidecar (legacy /
any non-sidecar third-party engine: ``blocks_path`` is empty)."""
content = result.get("content") or ""
print(f"engine : {result.get('parse_engine')}")
print(f"format : {result.get('parse_format')}")
print(f"content : {len(content)} chars")
if preview > 0 and content:
snippet = content[:400]
print(f"--- preview (first {len(snippet)} of {len(content)} chars) ---")
print(snippet + ("..." if len(content) > len(snippet) else ""))
async def _run(args: argparse.Namespace) -> int:
# Pipeline + heavy parser imports are deferred so ``--help`` and the
# input-file existence check don't pay for them.
from lightrag.constants import FULL_DOCS_FORMAT_PENDING_PARSE
from lightrag.parser.base import ParseContext
from lightrag.parser.external._base import ExternalParserBase
from lightrag.parser.registry import get_parser, suffix_capabilities
from lightrag.parser.debug import build_debug_rag
from lightrag.parser.docx.parse_document import DocxContentError
from lightrag.utils import compute_mdhash_id
import lightrag.pipeline as pipeline_mod
import lightrag.utils_pipeline as utils_pipeline_mod
source = args.input_file.resolve()
if not source.is_file():
print(f"error: input file does not exist: {source}", file=sys.stderr)
return 1
# Reject suffix/engine mismatches up-front: the pipeline would otherwise
# fail deep inside the IR builder with a less helpful message.
suffix = source.suffix.lstrip(".").lower()
supported = suffix_capabilities(args.engine)
if suffix not in supported:
print(
f"error: engine '{args.engine}' does not support .{suffix or '<no suffix>'} "
f"files (supported: {', '.join(sorted(supported))})",
file=sys.stderr,
)
return 1
parser = get_parser(args.engine)
if parser is None:
print(f"error: engine '{args.engine}' is not registered", file=sys.stderr)
return 1
is_external = isinstance(parser, ExternalParserBase)
sidecar_parent = (args.sidecar_parent_dir or source.parent).resolve()
sidecar_parent.mkdir(parents=True, exist_ok=True)
parsed_dir = sidecar_parent / f"{source.name}.parsed"
# External engines preserve a raw bundle next to the sidecar; its name is
# derived from the engine's own raw_dir_suffix (mirrors the flattened
# raw_dir_for_parsed_dir layout), so any external engine works here.
raw_dir = (
sidecar_parent / f"{source.name}{parser.raw_dir_suffix}"
if is_external
else None
)
doc_id = args.doc_id or compute_mdhash_id(str(source), prefix="doc-")
def _patched_artifact_dir(
file_path: str | None = None,
*,
parent_hint: Any | None = None,
) -> Path:
# Flatten the production "<INPUT_DIR>/__parsed__/<base>.parsed/"
# layout to "<sidecar_parent>/<source.name>.parsed/" so the sidecar
# and the source file sit side by side.
return parsed_dir
def _lenient_bundle(
raw_dir_arg: Path, _source_file: Path | None = None, *_args: Any, **_kwargs: Any
) -> bool:
# Tolerate the ExternalParserBase hook's keyword-only ``engine_params``
# (and any future args) — this stub replaces ``is_bundle_valid``.
return raw_dir_arg.exists() and any(raw_dir_arg.iterdir())
def _force_miss(*_args: Any, **_kwargs: Any) -> bool:
return False
bundle_check = _force_miss if args.force_reparse else _lenient_bundle
async def _noop_archive(*_args: Any, **_kwargs: Any) -> None:
return None
rag = build_debug_rag()
with ExitStack() as stack:
# Patch 1: redirect sidecar output to the flat layout.
# parsed_artifact_dir_for is from-imported into pipeline at
# module load, so patch both namespaces.
stack.enter_context(
mock.patch.object(
utils_pipeline_mod,
"parsed_artifact_dir_for",
_patched_artifact_dir,
)
)
stack.enter_context(
mock.patch.object(
pipeline_mod,
"parsed_artifact_dir_for",
_patched_artifact_dir,
)
)
# Patch 2: raw cache strategy. ExternalParserBase.parse calls
# ``self.is_bundle_valid(...)``, so patch the resolved instance method
# directly — works for any external engine without knowing its module.
if is_external:
stack.enter_context(
mock.patch.object(parser, "is_bundle_valid", bundle_check)
)
# Patch 3: keep the source file in place. Every engine archives via
# ctx.archive_source -> lightrag.pipeline.archive_docx_source_after_...
stack.enter_context(
mock.patch.object(
pipeline_mod,
"archive_docx_source_after_full_docs_sync",
_noop_archive,
)
)
try:
result = (
await parser.parse(
ParseContext(
rag,
doc_id,
str(source),
{
"parse_format": FULL_DOCS_FORMAT_PENDING_PARSE,
"content": "",
},
)
)
).to_dict()
except DocxContentError as exc:
# The native DOCX parser now raises (instead of sys.exit) on a
# content-limit violation so the server pipeline can fail just the
# offending document. Preserve the friendly CLI UX: print the
# formatted message and return a non-zero exit code, no traceback.
print(str(exc), file=sys.stderr)
return 1
blocks_path = result.get("blocks_path") or ""
if blocks_path:
_print_summary(Path(blocks_path), raw_dir, args.preview)
else:
# No sidecar (legacy / non-sidecar third-party engine): the result
# carries plain content, so summarize that instead of a blocks file.
_print_raw_summary(result, args.preview)
return 0
def main(argv: list[str] | None = None) -> int:
# Discover third-party parser engines before --engine choices are built,
# so a `lightrag.parsers` entry-point engine is selectable here exactly
# like in the server (which loads plugins in create_app).
from lightrag.parser.plugins import load_third_party_parsers
load_third_party_parsers()
args = _build_parser().parse_args(argv)
return asyncio.run(_run(args))
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
"""Shared debug LightRAG stand-in for registry-dispatched parsing.
A minimal ``LightRAG`` stand-in plus a deterministic ``datetime`` shim,
shared by the unified parser debug CLI (``lightrag/parser/cli.py``),
the golden-fixture regen script (``scripts/regen_native_docx_golden.py``),
and the byte-equivalence golden tests
(``tests/parser/docx/test_native_docx_golden.py``).
Every engine is driven the same way ``get_parser(engine).parse(
ParseContext(rag, ...))`` and ``ParseContext`` reads the same ``rag``
surface (``_persist_parsed_full_docs``, ``_resolve_source_file_for_parser``,
``self.full_docs``, ``self.doc_status``), so a single stand-in covers every
engine when a parser grows a new dependency, extend this module rather
than copy-pasting parallel stubs into each call site.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
class DebugFullDocs:
"""In-memory ``full_docs`` shim — captures the persisted record."""
def __init__(self) -> None:
self.data: dict[str, Any] = {}
async def upsert(self, payload: dict[str, Any]) -> None:
self.data.update(payload)
async def get_by_id(self, doc_id: str) -> Any:
return self.data.get(doc_id)
async def index_done_callback(self) -> None:
return None
class DebugDocStatus:
"""No-op ``doc_status`` shim — the parse_* methods never read/write it."""
async def get_by_id(self, doc_id: str) -> Any:
return None
async def upsert(self, data: dict[str, Any]) -> None:
return None
def build_debug_rag():
"""Build a minimal LightRAG stand-in that exposes what a parser reads.
The import of ``LightRAG`` is intentionally function-local: deferring
it avoids a circular import when this helper is loaded during package
init (the parser CLI resolves ``lightrag.parser.debug`` before
``lightrag`` itself is fully bound).
A parser is driven via ``get_parser(engine).parse(ParseContext(rag, ...))``
(CLI / golden tests / regen script). ``ParseContext`` reads these off the
``rag`` it is handed every entry MUST be provided by this stand-in:
- **methods** (rebound from :class:`LightRAG`):
- ``_persist_parsed_full_docs(doc_id, payload)`` async; touches
``self.full_docs``.
- ``_resolve_source_file_for_parser(file_path)`` returns the
on-disk source path. Stubbed to identity here since the CLI / tests
feed an already-resolved path.
- **storages**:
- ``self.full_docs.upsert(...)`` / ``.get_by_id(...)`` /
``.index_done_callback()`` :class:`DebugFullDocs` covers all three.
- ``self.doc_status.get_by_id(...)`` / ``.upsert(...)``
:class:`DebugDocStatus` covers both.
When a parser grows a new dependency on the ``rag`` handed to
``ParseContext``, extend this stand-in (and update the list above) rather
than copy-pasting a parallel stub into the call sites.
"""
from lightrag import LightRAG
class _DebugRag:
_persist_parsed_full_docs = LightRAG._persist_parsed_full_docs
def __init__(self) -> None:
self.full_docs = DebugFullDocs()
self.doc_status = DebugDocStatus()
def _resolve_source_file_for_parser(self, file_path: str) -> str:
return file_path
return _DebugRag()
_FROZEN_NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
class FrozenDateTime(datetime):
"""Pin ``datetime.now`` so ``write_sidecar`` stamps a deterministic time."""
@classmethod
def now(cls, tz=None): # noqa: D401
return _FROZEN_NOW if tz is None else _FROZEN_NOW.astimezone(tz)
+14
View File
@@ -0,0 +1,14 @@
"""LightRAG native DOCX parser package.
The :mod:`parse_document` / :mod:`numbering_resolver` / :mod:`table_extractor` /
:mod:`drawing_image_extractor` / :mod:`utils` / :mod:`omml` modules ship the
upstream DOCX extraction logic verbatim (with imports localized for the new
package path).
The pipeline-side orchestration (extract IR sidecar) now lives in
:meth:`lightrag.pipeline._PipelineMixin.parse_native` so the native and
MinerU engines share one shape; see :mod:`lightrag.parser.docx.ir_builder`
for the engine IR builder.
"""
__all__: list[str] = []
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""
ABOUTME: Shared drawing/image extraction utilities for DOCX parsing and editing
ABOUTME: Resolves w:drawing -> a:blip relationships, exports embedded images, builds placeholders
"""
from __future__ import annotations
import posixpath
import re
import shutil
import zipfile
from dataclasses import dataclass, field
from html import escape, unescape
from pathlib import Path, PurePosixPath
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
try:
from defusedxml import ElementTree as ET
except ImportError: # pragma: no cover
from xml.etree import ElementTree as ET
NS = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"v": "urn:schemas-microsoft-com:vml",
}
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPE_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
IMAGE_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
)
SOURCE_DOCUMENT_PART = "/word/document.xml"
# Match old and new drawing placeholders (requires id/name, allows extra attributes)
DRAWING_PATTERN = re.compile(
r'<drawing\b(?=[^>]*\bid="[^"]*")(?=[^>]*\bname="[^"]*")[^>]*/>'
)
DRAWING_TAG_PATTERN = re.compile(r"<drawing\b[^>]*/>")
DRAWING_ATTR_PATTERN = re.compile(r'([a-zA-Z_][\w:.-]*)="([^"]*)"')
@dataclass
class DrawingRelationship:
"""Relationship metadata for a single relationship ID."""
rel_id: str
target: str
target_mode: str
rel_type: str
part_name: Optional[str] = None
content_type: Optional[str] = None
image_format: Optional[str] = None
@dataclass
class DrawingExtractionContext:
"""Context used to resolve and export drawing images for one DOCX file."""
docx_path: Path
blocks_output_path: Optional[Path] = None
export_dir_name: Optional[str] = None
export_dir_path: Optional[Path] = None
relationships: Dict[str, DrawingRelationship] = field(default_factory=dict)
_exported_part_to_relpath: Dict[str, str] = field(default_factory=dict)
_used_filenames: Dict[str, str] = field(default_factory=dict)
def resolve_relationship(self, rel_id: str) -> Optional[DrawingRelationship]:
return self.relationships.get(rel_id)
def export_embedded_image(self, rel: DrawingRelationship) -> Optional[str]:
"""
Export an embedded image relationship target to export_dir.
Returns:
Relative path like "<blocks_stem>.image/image1.png" if exported,
or None when export is not applicable.
"""
if not self.export_dir_path or not self.export_dir_name:
return None
if rel.target_mode.lower() == "external":
return None
if not rel.part_name:
return None
if rel.part_name in self._exported_part_to_relpath:
return self._exported_part_to_relpath[rel.part_name]
zip_member = rel.part_name.lstrip("/")
try:
with zipfile.ZipFile(self.docx_path, "r") as zf:
blob = zf.read(zip_member)
except Exception:
return None
filename = self._dedupe_filename(PurePosixPath(rel.part_name).name or "image")
output_file = self.export_dir_path / filename
output_file.write_bytes(blob)
rel_path = str(PurePosixPath(self.export_dir_name) / filename)
self._exported_part_to_relpath[rel.part_name] = rel_path
return rel_path
def _dedupe_filename(self, base_name: str) -> str:
if base_name not in self._used_filenames:
self._used_filenames[base_name] = base_name
return base_name
stem = Path(base_name).stem
suffix = Path(base_name).suffix
index = 2
while True:
candidate = f"{stem}_{index}{suffix}"
if candidate not in self._used_filenames:
self._used_filenames[candidate] = candidate
return candidate
index += 1
def _normalize_image_format(ext_or_type: str) -> Optional[str]:
if not ext_or_type:
return None
value = ext_or_type.strip().lower()
# Content-Type
if value.startswith("image/"):
value = value.split("/", 1)[1]
if "+" in value:
value = value.split("+", 1)[0]
if value.startswith("x-"):
value = value[2:]
# Extension (with or without leading dot)
value = value.lstrip(".")
if value == "jpg":
return "jpeg"
if value in {"jpeg", "png", "gif", "bmp", "tiff", "webp", "svg", "emf", "wmf"}:
return value
return value or None
def _infer_format_from_target(target: str) -> Optional[str]:
if not target:
return None
parsed = urlparse(target)
path = parsed.path if parsed.scheme else target
suffix = PurePosixPath(path).suffix
return _normalize_image_format(suffix)
def _resolve_part_name(source_part_name: str, target: str) -> str:
if target.startswith("/"):
return posixpath.normpath(target)
source_dir = posixpath.dirname(source_part_name)
joined = posixpath.join(source_dir, target)
normalized = posixpath.normpath(joined)
if not normalized.startswith("/"):
normalized = "/" + normalized
return normalized
def create_drawing_context(
docx_path: str,
blocks_output_path: Optional[str] = None,
) -> DrawingExtractionContext:
"""
Create extraction context for a DOCX file.
If blocks_output_path is provided, this also prepares `<blocks_stem>.image/`
beside the blocks file and clears any previous content.
"""
docx_file = Path(docx_path)
ctx = DrawingExtractionContext(docx_path=docx_file)
if blocks_output_path:
output_path = Path(blocks_output_path)
export_dir_name = f"{output_path.stem}.image"
export_dir_path = output_path.parent / export_dir_name
if export_dir_path.exists():
shutil.rmtree(export_dir_path)
export_dir_path.mkdir(parents=True, exist_ok=True)
ctx.blocks_output_path = output_path
ctx.export_dir_name = export_dir_name
ctx.export_dir_path = export_dir_path
load_relationships(ctx)
return ctx
def load_relationships(ctx: DrawingExtractionContext) -> None:
rels_xml = "word/_rels/document.xml.rels"
content_types_xml = "[Content_Types].xml"
overrides: Dict[str, str] = {}
defaults: Dict[str, str] = {}
try:
with zipfile.ZipFile(ctx.docx_path, "r") as zf:
if content_types_xml in zf.namelist():
ct_root = ET.parse(zf.open(content_types_xml)).getroot()
for node in ct_root.findall(f".//{{{CONTENT_TYPE_NS}}}Override"):
part_name = node.get("PartName")
content_type = node.get("ContentType")
if part_name and content_type:
overrides[part_name] = content_type
for node in ct_root.findall(f".//{{{CONTENT_TYPE_NS}}}Default"):
ext = node.get("Extension")
content_type = node.get("ContentType")
if ext and content_type:
defaults[ext.lower()] = content_type
if rels_xml not in zf.namelist():
return
rels_root = ET.parse(zf.open(rels_xml)).getroot()
except Exception:
return
for rel in rels_root.findall(f".//{{{REL_NS}}}Relationship"):
rel_id = rel.get("Id")
target = rel.get("Target", "")
target_mode = rel.get("TargetMode", "")
rel_type = rel.get("Type", "")
if not rel_id:
continue
part_name = None
content_type = None
image_format = None
if target_mode.lower() != "external":
part_name = _resolve_part_name(SOURCE_DOCUMENT_PART, target)
if part_name:
content_type = overrides.get(part_name)
if not content_type:
ext = PurePosixPath(part_name).suffix.lower().lstrip(".")
content_type = defaults.get(ext)
image_format = _normalize_image_format(
content_type or _infer_format_from_target(part_name)
)
else:
image_format = _normalize_image_format(_infer_format_from_target(target))
ctx.relationships[rel_id] = DrawingRelationship(
rel_id=rel_id,
target=target,
target_mode=target_mode,
rel_type=rel_type,
part_name=part_name,
content_type=content_type,
image_format=image_format,
)
def _extract_blip_relationship(drawing_elem) -> Optional[Tuple[str, str]]:
for blip in drawing_elem.findall(".//a:blip", NS):
# Prefer explicit external links when both link/embed are present on one blip.
# Word may keep an embedded cache for linked pictures.
rel_link = blip.get(f"{{{NS['r']}}}link")
if rel_link:
return "link", rel_link
rel_embed = blip.get(f"{{{NS['r']}}}embed")
if rel_embed:
return "embed", rel_embed
return None
def _extract_imagedata_relationship(container_elem) -> Optional[str]:
"""Find an image relationship id from a w:pict / w:object via v:imagedata.
These legacy VML containers are how Word references EMF/WMF metafiles
(and the rendered preview of any embedded OLE object). v:imagedata uses
``r:id`` to point at the image part for both embedded and externally
linked images the relationship's ``TargetMode`` is what disambiguates
the two cases, so the caller must inspect the resolved relationship.
"""
r_id_attr = f"{{{NS['r']}}}id"
for imgdata in container_elem.findall(".//v:imagedata", NS):
rel_id = imgdata.get(r_id_attr)
if rel_id:
return rel_id
return None
def _build_placeholder(attrs: Dict[str, str]) -> str:
ordered_keys = ["id", "name", "path", "format"]
pieces = []
for key in ordered_keys:
if key in attrs and attrs[key] is not None:
pieces.append(f'{key}="{escape(str(attrs[key]), quote=True)}"')
# Preserve extra attributes deterministically (sorted by name)
for key in sorted(k for k in attrs.keys() if k not in ordered_keys):
value = attrs[key]
if value is not None:
pieces.append(f'{key}="{escape(str(value), quote=True)}"')
return f"<drawing {' '.join(pieces)} />"
def extract_drawing_placeholder_from_element(
drawing_elem,
context: Optional[DrawingExtractionContext] = None,
include_extended_attrs: bool = True,
) -> str:
"""
Build a <drawing ... /> placeholder from a w:drawing element.
Behavior:
- Always emits id/name from wp:docPr when present.
- For embedded images (a:blip@r:embed): exports image and sets path/format.
- For linked images (a:blip@r:link): does not download; path is original link target.
- When no image reference exists (e.g. chart drawing): keeps id/name only.
"""
doc_pr = drawing_elem.find(".//wp:docPr", NS)
attrs = {
"id": doc_pr.get("id", "") if doc_pr is not None else "",
"name": doc_pr.get("name", "") if doc_pr is not None else "",
}
if include_extended_attrs:
rel_ref = _extract_blip_relationship(drawing_elem)
if rel_ref is not None and context is not None:
rel_kind, rel_id = rel_ref
rel = context.resolve_relationship(rel_id)
if rel is not None:
if rel_kind == "embed" and rel.rel_type == IMAGE_REL_TYPE:
rel_path = context.export_embedded_image(rel)
if rel_path:
attrs["path"] = rel_path
if rel.image_format:
attrs["format"] = rel.image_format
elif rel_kind == "link":
if rel.target:
attrs["path"] = rel.target
if rel.image_format:
attrs["format"] = rel.image_format
return _build_placeholder(attrs)
def extract_vml_image_placeholder_from_element(
container_elem,
context: Optional[DrawingExtractionContext] = None,
include_extended_attrs: bool = True,
) -> str:
"""
Build a <drawing ... /> placeholder from a w:pict or w:object element.
Legacy Word documents and OLE-embedded objects (Visio diagrams, equation
editor previews, etc.) expose their rendered image via VML rather than
DrawingML. The image is referenced through ``<v:imagedata r:id="..."/>``
inside ``<v:shape>``, and the underlying bytes are commonly EMF/WMF
metafiles. This function exports those bytes through the same context as
DrawingML images so EMF/WMF assets land in the blocks.assets directory
alongside PNG/JPEG ones.
The output placeholder format matches
``extract_drawing_placeholder_from_element`` so downstream consumers
treat both paths uniformly.
"""
shape = container_elem.find(".//v:shape", NS)
attrs = {
"id": shape.get("id", "") if shape is not None else "",
"name": shape.get("alt", "") if shape is not None else "",
}
if include_extended_attrs:
rel_id = _extract_imagedata_relationship(container_elem)
if rel_id and context is not None:
rel = context.resolve_relationship(rel_id)
if rel is not None and rel.rel_type == IMAGE_REL_TYPE:
# VML reuses r:id for both embedded image parts and externally
# linked images; only the resolved TargetMode tells us which.
# Treating an external relationship as embedded would call
# export_embedded_image() (which short-circuits on external)
# and silently drop the linked path.
if rel.target_mode.lower() == "external":
if rel.target:
attrs["path"] = rel.target
if rel.image_format:
attrs["format"] = rel.image_format
else:
rel_path = context.export_embedded_image(rel)
if rel_path:
attrs["path"] = rel_path
if rel.image_format:
attrs["format"] = rel.image_format
return _build_placeholder(attrs)
def parse_drawing_attributes(placeholder: str) -> Dict[str, str]:
"""Parse attributes from a <drawing ... /> placeholder."""
return {
name: unescape(value)
for name, value in DRAWING_ATTR_PATTERN.findall(placeholder)
}
def normalize_drawing_placeholder(
placeholder: str,
include_extended_attrs: bool = False,
) -> str:
"""
Normalize one drawing placeholder into canonical attribute order.
Args:
placeholder: Input placeholder string
include_extended_attrs: If False, keeps only id/name.
"""
attrs = parse_drawing_attributes(placeholder)
normalized = {
"id": attrs.get("id", ""),
"name": attrs.get("name", ""),
}
if include_extended_attrs:
if "path" in attrs:
normalized["path"] = attrs["path"]
if "format" in attrs:
normalized["format"] = attrs["format"]
for key, value in attrs.items():
if key not in {"id", "name", "path", "format"}:
normalized[key] = value
return _build_placeholder(normalized)
def normalize_drawing_placeholders_in_text(
text: str,
include_extended_attrs: bool = False,
) -> str:
"""Normalize all drawing placeholders inside a text blob."""
if not text:
return text
def _replace(match: re.Match) -> str:
return normalize_drawing_placeholder(
match.group(0),
include_extended_attrs=include_extended_attrs,
)
return DRAWING_TAG_PATTERN.sub(_replace, text)
+339
View File
@@ -0,0 +1,339 @@
"""Native DOCX IR builder: ``extract_docx_blocks`` output → :class:`IRDoc`.
Input contract: a list of block dicts as produced by
``lightrag.parser.docx.parse_document.extract_docx_blocks``. Each
block carries ``content`` text in which ``<table>``, ``<equation>`` and
``<drawing />`` placeholders are already embedded by the upstream parser.
The builder rewrites those placeholders into IR placeholder tokens
(``{{TBL:k}} / {{EQ:k}} / {{EQI:k}} / {{IMG:k}}``) and builds the matching
``IRTable`` / ``IREquation`` / ``IRDrawing`` items.
Asset bytes are extracted to disk by the upstream parser *before* this
builder runs (via ``DrawingExtractionContext`` passed to
``extract_docx_blocks``). The builder therefore declares assets with
``AssetSpec.source=None`` the writer records each entry's size without
copying.
Block-vs-inline equation distinction follows the legacy native rule: an
``<equation></equation>`` tag is *block* iff each side is either the
content boundary or a ``\\n`` character. Anything else stays inline,
keeps its tag in block text without an id, and never enters
``equations.json``.
Positions are always emitted as ``IRPosition(type="paraid", range=[start,
end])`` where each side may be ``None`` (legacy / non-Word docx authors
sometimes omit ``w14:paraId``). The writer's ``to_jsonable`` faithfully
preserves the per-side null so consumers can distinguish "start missing"
vs "both missing".
"""
from __future__ import annotations
import itertools
import json
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Any
from lightrag.parser.docx.drawing_image_extractor import (
DRAWING_TAG_PATTERN,
parse_drawing_attributes,
)
from lightrag.sidecar.ir import (
AssetSpec,
IRBlock,
IRDoc,
IRDrawing,
IREquation,
IRPosition,
IRTable,
)
_TABLE_TAG_RE = re.compile(r"<table>(.*?)</table>", re.DOTALL)
_EQUATION_TAG_RE = re.compile(r"<equation>(.*?)</equation>", re.DOTALL)
def _normalize_dimension(rows_value: Any) -> tuple[int, int]:
if not isinstance(rows_value, list):
return 0, 0
num_rows = len(rows_value)
num_cols = max((len(r) for r in rows_value if isinstance(r, list)), default=0)
return num_rows, num_cols
def _placeholder_keyspace() -> Callable[[str], str]:
"""Return a fresh counter producing ``{prefix}{N}`` keys (1-indexed)."""
counter = itertools.count(1)
return lambda prefix: f"{prefix}{next(counter)}"
def _safe_asset_ref_from_path(path_val: str, asset_prefix: str) -> str | None:
"""Return the path inside ``asset_prefix`` only when it is safe.
Native DOCX images are pre-extracted into ``<base>.blocks.assets/``.
Treat a drawing path as local only when the suffix is a clean POSIX
relative path. Unsafe local-looking paths are dropped instead of being
registered as assets or preserved as linked references.
"""
if not asset_prefix or not path_val.startswith(asset_prefix):
return None
rel_raw = path_val[len(asset_prefix) :]
if not rel_raw or "\\" in rel_raw:
return None
rel_path = PurePosixPath(rel_raw)
if rel_path.is_absolute():
return None
if any(part == ".." for part in rel_path.parts):
return None
rel = rel_path.as_posix()
if rel in {"", "."}:
return None
return rel
@dataclass
class _BlockBuilder:
"""Per-block scratch state for the three ``re.sub`` rewrite passes.
Keeping the replacer routines as bound methods (rather than closures
redefined inside the per-block loop) means they're compiled once at
class-load and the state they mutate ``tables`` / ``drawings`` /
``equations`` / ``table_position`` is held explicitly rather than
captured implicitly from the enclosing frame.
"""
next_key: Callable[[str], str]
assets: list[AssetSpec]
seen_asset_refs: set[str]
asset_prefix: str
block_table_headers: list[Any]
tables: list[IRTable] = field(default_factory=list)
drawings: list[IRDrawing] = field(default_factory=list)
equations: list[IREquation] = field(default_factory=list)
# Position of the *next* ``<table>`` placeholder within this block,
# used to look up the matching entry in ``block_table_headers``.
table_position: int = 0
def replace_table(self, match: "re.Match[str]") -> str:
table_body_raw = match.group(1)
try:
rows = json.loads(table_body_raw)
if not isinstance(rows, list):
rows = None
except json.JSONDecodeError:
rows = None
if rows is not None:
parsed_rows: list[list[str]] | None = [
[str(c) for c in r] if isinstance(r, list) else [str(r)] for r in rows
]
html: str | None = None
else:
parsed_rows = None
html = table_body_raw
num_rows, num_cols = _normalize_dimension(parsed_rows)
header_pos = self.table_position
self.table_position += 1
header_rows = (
self.block_table_headers[header_pos]
if header_pos < len(self.block_table_headers)
else None
)
# Treat empty list / explicit None identically: no header
# entry on the sidecar item.
table_header = header_rows if header_rows else None
placeholder = self.next_key("tb")
self.tables.append(
IRTable(
placeholder_key=placeholder,
rows=parsed_rows,
html=html,
num_rows=num_rows,
num_cols=num_cols,
caption="",
footnotes=[],
table_header=table_header,
body_override=table_body_raw,
)
)
return f"{{{{TBL:{placeholder}}}}}"
def replace_equation(self, match: "re.Match[str]") -> str:
latex = match.group(1)
source = match.string
start, end = match.start(), match.end()
is_block = (start == 0 or source[start - 1] == "\n") and (
end == len(source) or source[end] == "\n"
)
placeholder = self.next_key("eq")
self.equations.append(
IREquation(
placeholder_key=placeholder,
latex=latex,
is_block=is_block,
caption="",
footnotes=[],
)
)
token = "EQ" if is_block else "EQI"
return f"{{{{{token}:{placeholder}}}}}"
def replace_drawing(self, match: "re.Match[str]") -> str:
attrs = parse_drawing_attributes(match.group(0))
path_val = attrs.get("path", "") or ""
src_val = attrs.get("src", "") or ""
fmt = attrs.get("format", "") or ""
if not fmt and path_val:
fmt = Path(path_val).suffix.lower().lstrip(".")
# Two flavours of <drawing path="…">:
# 1. Local asset under <base>.blocks.assets/ — already
# extracted to disk by DrawingExtractionContext;
# register as AssetSpec(source=None) and let the
# writer resolve the path via asset_paths.
# 2. External/linked path (URL, or any path that does
# not live under asset_prefix) — pass through
# verbatim via IRDrawing.path_override; do NOT emit
# an AssetSpec (no on-disk bytes to materialize).
rel_inside_assets = _safe_asset_ref_from_path(path_val, self.asset_prefix)
if rel_inside_assets is not None:
asset_ref = rel_inside_assets
suggested_name = Path(rel_inside_assets).name or rel_inside_assets
if asset_ref and asset_ref not in self.seen_asset_refs:
self.assets.append(
AssetSpec(
ref=asset_ref,
suggested_name=suggested_name,
source=None, # already extracted to disk
)
)
self.seen_asset_refs.add(asset_ref)
path_override: str | None = None
else:
asset_ref = ""
# Only mark as an external/linked reference when the
# upstream parser actually emitted a path. An empty
# ``path=""`` should fall back to the regular asset-
# resolution path (which will also produce ``path=""``
# downstream) rather than masquerading as an explicit
# builder override.
path_override = (
None
if self.asset_prefix and path_val.startswith(self.asset_prefix)
else path_val or None
)
placeholder = self.next_key("im")
self.drawings.append(
IRDrawing(
placeholder_key=placeholder,
asset_ref=asset_ref,
fmt=fmt,
caption="",
footnotes=[],
src=src_val,
path_override=path_override,
)
)
return f"{{{{IMG:{placeholder}}}}}"
class NativeDocxIRBuilder:
"""Translate ``extract_docx_blocks`` output into an :class:`IRDoc`.
The builder is stateless instantiate per call. ``asset_dir_name`` is
the relative name (without trailing slash) of ``<base>.blocks.assets/``
that the upstream parser used when emitting ``<drawing path>``
attributes; the builder strips that prefix when building
:attr:`AssetSpec.ref` so the writer's ref↔filename mapping has
predictable keys.
"""
def normalize(
self,
blocks: list[dict[str, Any]],
*,
document_name: str,
asset_dir_name: str,
parse_metadata: dict[str, Any] | None = None,
) -> IRDoc:
next_key = _placeholder_keyspace()
ir_blocks: list[IRBlock] = []
assets: list[AssetSpec] = []
seen_asset_refs: set[str] = set()
asset_prefix = f"{asset_dir_name}/" if asset_dir_name else ""
for block in blocks:
raw_content = block.get("content") or ""
heading = block.get("heading") or ""
level = int(block.get("level", 0) or 0)
parent_headings = list(block.get("parent_headings") or [])
# Preserve per-side nulls in [start, end].
uuid_start = block.get("uuid") or None
uuid_end = block.get("uuid_end") or None
builder = _BlockBuilder(
next_key=next_key,
assets=assets,
seen_asset_refs=seen_asset_refs,
asset_prefix=asset_prefix,
block_table_headers=list(block.get("table_headers") or []),
)
# Rewrite order matches the legacy native flow: tables, then
# equations, then drawings — each ``re.sub`` operates on the
# output of the previous pass.
content_template = _TABLE_TAG_RE.sub(builder.replace_table, raw_content)
content_template = _EQUATION_TAG_RE.sub(
builder.replace_equation, content_template
)
content_template = DRAWING_TAG_PATTERN.sub(
builder.replace_drawing, content_template
)
positions = [
IRPosition(type="paraid", range=[uuid_start, uuid_end]),
]
ir_blocks.append(
IRBlock(
content_template=content_template,
heading=heading,
level=level,
parent_headings=parent_headings,
positions=positions,
tables=builder.tables,
drawings=builder.drawings,
equations=builder.equations,
)
)
# doc_title: parse_metadata["first_heading"] when present, else file
# stem fallback (resolved here so the writer doesn't have to know).
first_heading = ""
if isinstance(parse_metadata, dict):
first_heading = str(parse_metadata.get("first_heading") or "")
doc_title = first_heading or (Path(document_name).stem or document_name)
return IRDoc(
document_name=document_name,
document_format=Path(document_name).suffix.lower().lstrip("."),
doc_title=doc_title,
split_option={},
blocks=ir_blocks,
assets=assets,
bbox_attributes=None,
)
__all__ = ["NativeDocxIRBuilder"]
+423
View File
@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
ABOUTME: Resolves automatic numbering labels from DOCX documents
ABOUTME: Parses numbering.xml and computes rendered number strings
"""
import zipfile
from defusedxml import ElementTree as ET
from typing import Dict
NSMAP = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
class NumberingResolver:
"""
Resolves paragraph numbering to rendered label strings.
DOCX stores numbering definitions in numbering.xml:
- abstractNum: Defines format templates (lvlText like "%1.%2.")
- num: Links numId to abstractNumId
Each paragraph references: numId (which definition) + ilvl (which level)
"""
# Number format converters
FORMAT_CONVERTERS = {
"decimal": lambda n: str(n),
"lowerLetter": lambda n: chr(ord("a") + (n - 1) % 26),
"upperLetter": lambda n: chr(ord("A") + (n - 1) % 26),
"lowerRoman": lambda n: NumberingResolver._to_roman(n).lower(),
"upperRoman": lambda n: NumberingResolver._to_roman(n),
"chineseCountingThousand": lambda n: NumberingResolver._to_chinese(n),
"ideographTraditional": lambda n: "甲乙丙丁戊己庚辛壬癸"[(n - 1) % 10],
"bullet": lambda n: "",
"none": lambda n: "",
}
def __init__(self, docx_path: str):
self.abstract_nums: Dict[str, dict] = {} # abstractNumId -> level definitions
self.num_to_abstract: Dict[str, str] = {} # numId -> abstractNumId
self.counters: Dict[
str, Dict[int, int]
] = {} # numId -> {ilvl -> current_count}
self.start_overrides: Dict[
str, Dict[int, int]
] = {} # numId -> {ilvl -> start_value}
self.style_numpr: Dict[
str, dict
] = {} # styleId -> {numId, ilvl} from styles.xml
self.style_based_on: Dict[str, str] = {} # styleId -> basedOn styleId
# Smart numbering merge state (Word's rendering behavior)
self.last_numId: str = None # Previous paragraph's numId
self.last_abstract_id: str = None # Previous paragraph's abstractNumId
self.last_style_id: str = None # Previous paragraph's style ID
self._parse_numbering_xml(docx_path)
self._parse_styles_xml(docx_path)
def _parse_numbering_xml(self, docx_path: str):
"""Parse numbering.xml from DOCX archive"""
try:
with zipfile.ZipFile(docx_path, "r") as zf:
if "word/numbering.xml" not in zf.namelist():
return
tree = ET.parse(zf.open("word/numbering.xml"))
root = tree.getroot()
# Parse abstractNum definitions
for abstract in root.findall(".//w:abstractNum", NSMAP):
abstract_id = abstract.get(f"{{{NSMAP['w']}}}abstractNumId")
levels = {}
for lvl in abstract.findall("w:lvl", NSMAP):
ilvl = int(lvl.get(f"{{{NSMAP['w']}}}ilvl"))
start_elem = lvl.find("w:start", NSMAP)
start = (
int(start_elem.get(f"{{{NSMAP['w']}}}val"))
if start_elem is not None
else 1
)
num_fmt_elem = lvl.find("w:numFmt", NSMAP)
num_fmt = (
num_fmt_elem.get(f"{{{NSMAP['w']}}}val")
if num_fmt_elem is not None
else "decimal"
)
lvl_text_elem = lvl.find("w:lvlText", NSMAP)
lvl_text = (
lvl_text_elem.get(f"{{{NSMAP['w']}}}val")
if lvl_text_elem is not None
else "%1."
)
is_lgl_elem = lvl.find("w:isLgl", NSMAP)
is_lgl = False
if is_lgl_elem is not None:
val = is_lgl_elem.get(f"{{{NSMAP['w']}}}val")
is_lgl = val is None or val not in ("0", "false")
levels[ilvl] = {
"start": start,
"numFmt": num_fmt,
"lvlText": lvl_text,
"isLgl": is_lgl,
}
self.abstract_nums[abstract_id] = levels
# Parse num -> abstractNum mapping and startOverride
for num in root.findall(".//w:num", NSMAP):
num_id = num.get(f"{{{NSMAP['w']}}}numId")
abstract_ref = num.find("w:abstractNumId", NSMAP)
if abstract_ref is not None:
self.num_to_abstract[num_id] = abstract_ref.get(
f"{{{NSMAP['w']}}}val"
)
# Parse lvlOverride/startOverride for this num
for lvl_override in num.findall("w:lvlOverride", NSMAP):
ilvl = int(lvl_override.get(f"{{{NSMAP['w']}}}ilvl"))
start_override = lvl_override.find("w:startOverride", NSMAP)
if start_override is not None:
start_val = int(start_override.get(f"{{{NSMAP['w']}}}val"))
if num_id not in self.start_overrides:
self.start_overrides[num_id] = {}
self.start_overrides[num_id][ilvl] = start_val
except Exception:
# Silently ignore parsing errors - document may not have numbering
pass
def _parse_styles_xml(self, docx_path: str):
"""Parse styles.xml to get style-inherited numbering definitions"""
try:
with zipfile.ZipFile(docx_path, "r") as zf:
if "word/styles.xml" not in zf.namelist():
return
tree = ET.parse(zf.open("word/styles.xml"))
root = tree.getroot()
# Parse style definitions
for style in root.findall(".//w:style", NSMAP):
style_id = style.get(f"{{{NSMAP['w']}}}styleId")
if not style_id:
continue
# Check for basedOn (style inheritance)
based_on = style.find("w:basedOn", NSMAP)
if based_on is not None:
parent_id = based_on.get(f"{{{NSMAP['w']}}}val")
if parent_id:
self.style_based_on[style_id] = parent_id
# Check for numPr in style's pPr
pPr = style.find("w:pPr", NSMAP)
if pPr is not None:
numPr = pPr.find("w:numPr", NSMAP)
if numPr is not None:
num_id_elem = numPr.find("w:numId", NSMAP)
ilvl_elem = numPr.find("w:ilvl", NSMAP)
if num_id_elem is not None:
num_id = num_id_elem.get(f"{{{NSMAP['w']}}}val")
ilvl = (
int(ilvl_elem.get(f"{{{NSMAP['w']}}}val"))
if ilvl_elem is not None
else 0
)
self.style_numpr[style_id] = {
"numId": num_id,
"ilvl": ilvl,
}
except Exception:
# Silently ignore parsing errors
pass
def _get_numbering_from_style(self, style_id: str, visited=None) -> dict:
"""
Get numbering definition from style, following inheritance chain.
Args:
style_id: Style ID to look up
visited: Set of visited style IDs (to prevent circular references)
Returns:
dict with 'numId' and 'ilvl', or None
"""
if visited is None:
visited = set()
# Prevent circular references
if style_id in visited:
return None
visited.add(style_id)
# Check if this style has numPr
if style_id in self.style_numpr:
return self.style_numpr[style_id]
# Check parent style
if style_id in self.style_based_on:
parent_id = self.style_based_on[style_id]
return self._get_numbering_from_style(parent_id, visited)
return None
def reset_tracking_state(self):
"""
Reset numbering tracking state.
Call this when encountering structural breaks that should
interrupt numbering continuity:
- Section breaks (sectPr)
- Table boundaries (before and after tables)
This prevents incorrect numbering continuation across
document structure boundaries.
"""
self.last_numId = None
self.last_abstract_id = None
self.last_style_id = None
def get_label(self, para_element) -> str:
"""
Get rendered numbering label for a paragraph.
Checks both direct numPr and style-inherited numbering. Direct numPr
is a paragraph-local override and applies only to the current
paragraph; subsequent paragraphs that carry only pStyle fall back to
the style's numPr declared in styles.xml.
Args:
para_element: lxml Element for <w:p>
Returns:
Rendered label string (e.g., "1.1", "a)", "第一章") or empty string
"""
try:
pPr = para_element.find(f"{{{NSMAP['w']}}}pPr")
if pPr is None:
return ""
num_id = None
ilvl = 0
style_id = None
# Get pStyle (if present)
pStyle = pPr.find(f"{{{NSMAP['w']}}}pStyle")
if pStyle is not None:
style_id = pStyle.get(f"{{{NSMAP['w']}}}val")
# Check for direct numPr in paragraph
numPr = pPr.find(f"{{{NSMAP['w']}}}numPr")
if numPr is not None:
num_id_elem = numPr.find(f"{{{NSMAP['w']}}}numId")
ilvl_elem = numPr.find(f"{{{NSMAP['w']}}}ilvl")
if num_id_elem is not None:
num_id = num_id_elem.get(f"{{{NSMAP['w']}}}val")
ilvl = (
int(ilvl_elem.get(f"{{{NSMAP['w']}}}val"))
if ilvl_elem is not None
else 0
)
# If no direct numPr, fall back to style-inherited numbering.
# Direct numPr is a paragraph-local override in Word; it must not
# persist as a runtime default for the style, otherwise subsequent
# paragraphs that only carry pStyle will keep following the local
# override instead of the style's declared numPr.
if num_id is None and style_id:
style_num = self._get_numbering_from_style(style_id)
if style_num:
num_id = style_num["numId"]
ilvl = style_num["ilvl"]
# If still no numbering found, clear state and return empty
if num_id is None:
# We should use list structure breaking logic to reset last_numId, last_abstract_id and last_style_id
return ""
# Get abstract definition
abstract_id = self.num_to_abstract.get(num_id)
if abstract_id is None or abstract_id not in self.abstract_nums:
# Clear state for invalid numbering
self.last_numId = None
self.last_abstract_id = None
return ""
levels = self.abstract_nums[abstract_id]
if ilvl not in levels:
# Clear state for invalid level
self.last_numId = None
self.last_abstract_id = None
return ""
# Smart numbering merge: (Word's rendering behavior)
# When consecutive paragraphs have different numId but same abstractNumId,
# Word continues the numbering sequence rather than restarting.
# This happens regardless of whether the numId is new or style matches.
if (
self.last_numId is not None
and self.last_numId != num_id
and self.last_abstract_id == abstract_id
and self.last_numId in self.counters
):
# Merge: copy previous numId's counter to current numId
self.counters[num_id] = self.counters[self.last_numId].copy()
# Initialize/update counter
if num_id not in self.counters:
self.counters[num_id] = {}
# Initialize all parent levels if not present (for deep nested numbering)
for i in range(ilvl):
if i not in self.counters[num_id] and i in levels:
# Use startOverride if exists, otherwise use abstractNum's start value
if (
num_id in self.start_overrides
and i in self.start_overrides[num_id]
):
self.counters[num_id][i] = self.start_overrides[num_id][i]
else:
self.counters[num_id][i] = levels[i]["start"]
# Reset lower levels when higher level increments
for i in range(ilvl + 1, 10):
if i in self.counters[num_id]:
del self.counters[num_id][i]
# Initialize current level if needed
if ilvl not in self.counters[num_id]:
# Use startOverride if exists, otherwise use abstractNum's start value
if (
num_id in self.start_overrides
and ilvl in self.start_overrides[num_id]
):
self.counters[num_id][ilvl] = self.start_overrides[num_id][ilvl]
else:
self.counters[num_id][ilvl] = levels[ilvl]["start"]
else:
self.counters[num_id][ilvl] += 1
# Format the label using lvlText template
label = self._format_label(num_id, ilvl, levels)
# Update tracking state for next paragraph
self.last_numId = num_id
self.last_abstract_id = abstract_id
self.last_style_id = style_id
return label
except Exception:
# Return empty on any error to avoid breaking document parsing
return ""
def _format_label(self, num_id: str, ilvl: int, levels: dict) -> str:
"""Format label string by replacing %1, %2, etc."""
try:
lvl_text = levels[ilvl]["lvlText"]
result = lvl_text
current_is_lgl = levels[ilvl].get("isLgl", False)
for i in range(ilvl + 1):
if i in levels and i in self.counters.get(num_id, {}):
num_fmt = levels[i]["numFmt"]
if current_is_lgl and i < ilvl:
num_fmt = "decimal"
count = self.counters[num_id][i]
converter = self.FORMAT_CONVERTERS.get(num_fmt, lambda n: str(n))
formatted = converter(count)
result = result.replace(f"%{i + 1}", formatted)
return result
except Exception:
return ""
@staticmethod
def _to_roman(n: int) -> str:
"""Convert integer to Roman numeral"""
if n <= 0 or n >= 4000:
return str(n)
values = [
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
]
result = ""
for value, numeral in values:
while n >= value:
result += numeral
n -= value
return result
@staticmethod
def _to_chinese(n: int) -> str:
"""Convert integer to Chinese numeral"""
digits = "零一二三四五六七八九"
if n <= 0 or n > 99:
return str(n)
if n < 10:
return digits[n]
if n < 20:
return "" + (digits[n % 10] if n % 10 else "")
if n < 100:
tens = n // 10
ones = n % 10
return digits[tens] + "" + (digits[ones] if ones else "")
return str(n)
+10
View File
@@ -0,0 +1,10 @@
"""
ABOUTME: OMML (Office Math Markup Language) to LaTeX conversion
"""
from .ommlparser import OMMLParser
def convert_omml_to_latex(omml_element) -> str:
"""Convert an m:oMath XML element to a LaTeX string."""
return OMMLParser().parse(omml_element)
+38
View File
@@ -0,0 +1,38 @@
"""
Postprocessing functions for cleaning up latex equations in linear format which don't give valid LaTeX.
"""
import re
clean_exps = {
r"\\degf": "&deg;F",
r"\\degc": "&deg;C",
r"(\\cbrt)(\w+)": r"\\sqrt[3]{\2}",
r"(\\qdrt)(\w+)": r"\\sqrt[4]{\2}",
r"\\sfrac": r"\\frac",
r"(\\o[i]+nt)(\w+)": r"\1{\2}",
r"\\bullet(\w+)": r"\\bullet \1",
r"\\sum([a-zA-Z0-9]+)": r"\\sum{\1}",
r"\\prod([a-zA-Z0-9]+)": r"\\prod{\1}",
r"\\amalg([a-zA-Z0-9]+)": r"\\amalg{\1}",
r"\\bigcup([a-zA-Z0-9]+)": r"\\bigcup{\1}",
r"\\bigcap([a-zA-Z0-9]+)": r"\\bigcap{\1}",
r"\\bigvee([a-zA-Z0-9]+)": r"\\bigvee{\1}",
r"\\bigwedge([a-zA-Z0-9]+)": r"\\bigwedge{\1}",
r"\\lfloor([a-zA-Z0-9]+)": r"\\lfloor{\1}",
r"\\lceil([a-zA-Z0-9]+)": r"\\lceil{\1}",
r"\\lim\\below\{(.+)\}\{(.+)\}": r"\\lim_{\1}{\2}",
r"\\min\\below\{(.+)\}\{(.+)\}": r"\\min_{\1}{\2}",
r"\\max\\below\{(.+)\}\{(.+)\}": r"\\max_{\1}{\2}",
}
def clean_exp(exp):
"""
Takes in a linear expression and converts known invalid LaTeX equations to valid LaTeX
:param exp:str - An equation in invalid syntax
:return :str - A valid equation
"""
for e in clean_exps:
exp = re.sub(e, clean_exps[e], exp)
return exp
+511
View File
@@ -0,0 +1,511 @@
from xml.etree.cElementTree import Element
from .utils import qn
class OMMLParser:
"""
Parser class for reading OMML and converting it into LaTeX.
"""
FUNCTION_MAP = {
"sin": "\\sin",
"cos": "\\cos",
"tan": "\\tan",
"cot": "\\cot",
"sec": "\\sec",
"csc": "\\csc",
"sinh": "\\sinh",
"cosh": "\\cosh",
"tanh": "\\tanh",
"coth": "\\coth",
"sech": "\\operatorname{sech}",
"csch": "\\operatorname{csch}",
"log": "\\log",
"ln": "\\ln",
"min": "\\min",
"max": "\\max",
"lim": "\\lim",
}
def _normalize_func_name(self, content: str) -> str:
if not content:
return content
if content.startswith("\\"):
return content
key = content.strip()
mapped = self.FUNCTION_MAP.get(key)
return mapped if mapped else content
def parse(self, root: Element) -> str:
"""
Parses an m:oMath OMML tag into LaTeX.
:param root: An m:oMath OMML tag
:return: The LaTeX representation of the OMML input
"""
text = ""
try:
if root.tag == qn("m:t"):
return self.parse_t(root)
for child in root:
if child.tag in self.parsers:
text += self.parsers[child.tag](self, child)
except AttributeError:
# In case of missing attributes on OMML tags,
# we return an empty string (ref:issue_14)
return ""
return text
def parse_e(self, root: Element) -> str:
text = ""
for child in root:
text += self.parse(child)
return text
def parse_r(self, root: Element) -> str:
# TODO: Add support for m:rPr and m:scr to support different character styles
# For now, we just parse the text content of m:r
text = ""
for child in root:
text += self.parse(child)
return text
def parse_t(self, root: Element):
symbol_map = {
"": "\\triangleq",
"": "\\stackrel{\\tiny def}{=}",
"": "\\stackrel{\\tiny m}{=}",
}
replacements = {
"&lt;": "\\lt ",
"&gt;": "\\gt ",
"&le;": "\\leq ",
"&ge;": "\\geq ",
"": "\\infty ",
"<": "\\lt ",
">": "\\gt ",
"": "\\leq ",
"": "\\geq ",
}
text = root.text.split()
if not text:
return " "
for i, t in enumerate(text):
if t in symbol_map:
text[i] = symbol_map[t]
for key, value in replacements.items():
for i, t in enumerate(text):
text[i] = t.replace(key, value)
return " ".join(text)
def parse_acc(self, root: Element) -> str:
character_map = {
768: "\\grave",
769: "\\acute",
770: "\\hat",
771: "\\tilde",
773: "\\bar",
774: "\\breve",
775: "\\dot",
776: "\\ddot",
780: "\\check",
831: "\\overline{\\overline",
8400: "\\overset\\leftharpoonup",
8401: "\\overset\\rightharpoonup",
8406: "\\overleftarrow",
8407: "\\overrightarrow",
8411: "\\dddot",
8417: "\\overset\\leftrightarrow",
}
text = ""
accent = 770
for child in root:
if child.tag == qn("m:accPr"):
for child2 in child:
if child2.tag == qn("m:chr"):
val = child2.attrib.get(qn("m:val"))
if val:
try:
accent = ord(val)
except TypeError:
pass
accent_cmd = character_map.get(accent)
if accent_cmd is None:
accent_cmd = character_map.get(770, "\\hat")
text += accent_cmd + "{"
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child)
text += "}"
if accent == 831:
text += "}"
return text
def parse_bar(self, root: Element) -> str:
text = "\\overline{"
for child in root:
if child.tag == qn("m:barPr"):
for child2 in child:
if child2.tag == qn("m:pos"):
if child2.attrib.get(qn("m:val")) == "bot":
text = "\\underline{"
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child)
text += "}"
return text
def parse_border_box(self, root: Element) -> str:
text = "\\boxed{"
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child)
text += "}"
return text
def parse_box(self, root: Element) -> str:
text = ""
for child in root:
text += self.parse(child)
return text
def parse_group_chr(self, root: Element) -> str:
character_map = {
"": "\\leftarrow",
"": "\\rightarrow",
"": "\\leftrightarrow",
"": "\\Leftarrow",
"": "\\Rightarrow",
"": "\\Leftrightarrow",
}
text = "\\underbrace{"
bottom = False
for child in root:
if child.tag == qn("m:groupChrPr"):
for child2 in child:
if child2.tag == qn("m:chr"):
char = child2.attrib.get(qn("m:val"))
if char in character_map:
text = character_map[char]
for child2 in child:
if (
child2.tag == qn("m:pos")
and child2.attrib.get(qn("m:val")) == "top"
):
# If m:pos is set to "top", the symbol is supposed to
# be on top and the text is actually supposed to be under
bottom = True
content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if text == "\\underbrace{":
if bottom:
text = "\\overbrace{" + content + "}"
else:
text += content + "}"
else:
if not bottom:
text = "\\overset{" + content + "}" + "{" + text + "}"
else:
text = "\\underset{" + content + "}" + "{" + text + "}"
return text
def parse_d(self, root: Element) -> str:
bracket_map = {
"(": "\\left(",
")": "\\right)",
"[": "\\left[",
"]": "\\right]",
"{": "\\left{",
"}": "\\right}",
"": "\\left\\langle",
"": "\\right\\rangle",
"": "\\left\\langle",
"": "\\right\\rangle",
"": "\\left\\lfloor",
"": "\\right\\rfloor",
"": "\\left\\lceil",
"": "\\right\\rceil",
"|": "\\left|",
"": "\\left\\|",
"": "[\\![",
"": "]\\!]",
}
text = ""
start_bracket = "("
end_bracket = ")"
seperator = "|"
is_matrix = False
for child in root:
for child2 in child:
if child.tag == qn("m:dPr"):
if child2.tag == qn("m:begChr"):
start_bracket = child2.attrib.get(qn("m:val"))
if child2.tag == qn("m:endChr"):
end_bracket = child2.attrib.get(qn("m:val"))
if child2.tag == qn("m:sepChr"):
seperator = child2.attrib.get(qn("m:val"))
if child2.tag == qn("m:m"):
is_matrix = True
for child in root:
if child.tag == qn("m:e"):
if text:
text += seperator
text += self.parse(child)
end_bracket_replacements = {
"|": "\\right|",
"": "\\right\\|",
"[": "\\right[",
}
start_bracket_replacements = {
"]": "\\left]",
}
start = ""
end = ""
if start_bracket:
if start_bracket in start_bracket_replacements:
start = start_bracket_replacements[start_bracket] + " "
elif start_bracket in bracket_map:
start = bracket_map[start_bracket] + " "
else:
start = "\\left(" + " "
if end_bracket:
if end_bracket in end_bracket_replacements:
end = " " + end_bracket_replacements[end_bracket]
elif end_bracket in bracket_map:
end = " " + bracket_map[end_bracket]
else:
end = " " + "\\right)"
# If there is no end bracket and this tag contains an m:eqArr tag as a
# child, we assume that the eqArr should be translated to a cases environment
# instead of an eqnarray* environment.
else:
for child in root:
if child.tag == qn("m:e"):
for child2 in child:
if child2.tag == qn("m:eqArr"):
text = text.replace("\\begin{eqnarray*}", "")
text = text.replace("\\end{eqnarray*}", "")
return "\\begin{cases} " + text + " \\end{cases}"
if is_matrix:
if start_bracket == "(" and end_bracket == ")":
return text.replace("{matrix}", "{pmatrix}")
elif start_bracket == "|" and end_bracket == "|":
return text.replace("{matrix}", "{vmatrix}")
elif start_bracket == "" and end_bracket == "":
return text.replace("{matrix}", "{Vmatrix}")
else:
return text.replace("{matrix}", "{bmatrix}")
return start + text + end
def parse_eq_arr(self, root: Element) -> str:
text = "\\begin{eqnarray*}"
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child) + " \\\\"
text += "\\end{eqnarray*}"
return text
def parse_f(self, root: Element) -> str:
text = "\\frac{"
num = ""
den = ""
is_binom = False
for child in root:
if child.tag == qn("m:fPr"):
for child2 in child:
if (
child2.tag == qn("m:type")
and child2.attrib.get(qn("m:val")) == "noBar"
):
is_binom = True
if child.tag == qn("m:num"):
num = self.parse(child)
if child.tag == qn("m:den"):
den = self.parse(child)
if is_binom:
text = "\\genfrac{}{}{0pt}{}{"
text += num + "}{" + den + "}"
return text
def parse_m(self, root: Element) -> str:
text = "\\begin{matrix} "
text += self.parse(root)[:-3] # Remove the last ' \\'
text += "\\end{matrix}"
return text
def parse_mr(self, root: Element) -> str:
text = ""
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child) + " & "
return text[:-2] + "\\\\ " # Remove the last ' & '
def parse_func(self, root: Element) -> str:
subscript = ""
superscript = ""
text = ""
func_name = "sin"
for child in root:
if child.tag == qn("m:fName"):
for child2 in child:
if child2.tag in [qn("m:sSup"), qn("m:sSub"), qn("m:r")]:
for child3 in child2:
if child3.tag == qn("m:sub"):
subscript = self.parse(child3)
if child3.tag == qn("m:sup"):
superscript = self.parse(child3)
if child3.tag == qn("m:t") or child3.tag == qn("m:e"):
func_name = self.parse(child3)
elif child2.tag == qn("m:limLow"):
for child3 in child2:
if child3.tag == qn("m:lim"):
for child4 in child3:
subscript += self.parse(child4)
if child3.tag == qn("m:e"):
func_name = self.parse(child3)
if child.tag == qn("m:e"):
text += self.parse(child)
if func_name in ["lim", "max", "min"]:
return f"\\{func_name}\\limits_{{{subscript}}}^{{{superscript}}}{{{text}}}"
if func_name not in self.FUNCTION_MAP:
return f"{{{func_name}}}^{{{superscript}}}_{{{subscript}}}{{{text}}}"
return (
self.FUNCTION_MAP[func_name]
+ f"_{{{subscript}}}^{{{superscript}}}{{{text}}}"
)
def parse_s_sup(self, root: Element) -> str:
content = ""
exp_content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if child.tag == qn("m:sup"):
exp_content = self.parse(child)
content = self._normalize_func_name(content)
return f"{{{content}}}^{{{exp_content}}}"
def parse_s_sub(self, root: Element) -> str:
content = ""
sub_content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if child.tag == qn("m:sub"):
sub_content = self.parse(child)
content = self._normalize_func_name(content)
return f"{{{content}}}_{{{sub_content}}}"
def parse_s_sub_sup(self, root: Element) -> str:
content = ""
sub_content = ""
exp_content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if child.tag == qn("m:sub"):
sub_content = self.parse(child)
if child.tag == qn("m:sup"):
exp_content = self.parse(child)
content = self._normalize_func_name(content)
return f"{{{content}}}_{{{sub_content}}}^{{{exp_content}}}"
def parse_s_pre(self, root: Element) -> str:
content = ""
sub_content = ""
exp_content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if child.tag == qn("m:sub"):
sub_content = self.parse(child)
if child.tag == qn("m:sup"):
exp_content = self.parse(child)
return "{}^{" + exp_content + "}_{" + sub_content + "}{" + content + "}"
def parse_rad(self, root: Element) -> str:
content = ""
order = ""
for child in root:
if child.tag == qn("m:deg"):
order = self.parse(child)
if child.tag == qn("m:e"):
content += self.parse(child)
if order:
return f"\\sqrt[{order}]{{{content}}}"
return f"\\sqrt{{{content}}}"
def parse_nary(self, root: Element) -> str:
character_map = {
8719: "\\prod",
8720: "\\coprod",
8721: "\\sum",
8747: "\\int",
8748: "\\iint",
8749: "\\iiint",
8750: "\\oint",
8751: "\\oiint",
8752: "\\oiiint",
8896: "\\bigwedge",
8897: "\\bigvee",
8898: "\\bigcap",
8899: "\\bigcup",
}
char = 8747
for child in root:
if child.tag == qn("m:naryPr"):
for child2 in child:
if child2.tag == qn("m:chr"):
val = child2.attrib.get(qn("m:val"))
if val:
try:
char = ord(val)
except TypeError:
pass
text = character_map.get(char, character_map[8721])
sub = ""
sup = ""
content = ""
for child in root:
if child.tag == qn("m:sub"):
sub = self.parse(child)
if child.tag == qn("m:sup"):
sup = self.parse(child)
if child.tag == qn("m:e"):
content = self.parse(child)
if sub:
text += f"_{{{sub}}}"
if sup:
text += f"^{{{sup}}}"
text += "{" + content + "}"
return text
parsers = {
qn("m:r"): parse_r,
qn("m:acc"): parse_acc,
qn("m:borderBox"): parse_border_box,
qn("m:bar"): parse_bar,
qn("m:box"): parse_box,
qn("m:d"): parse_d,
qn("m:e"): parse_e,
qn("m:groupChr"): parse_group_chr,
qn("m:f"): parse_f,
qn("m:sSup"): parse_s_sup,
qn("m:sSub"): parse_s_sub,
qn("m:sSubSup"): parse_s_sub_sup,
qn("m:sPre"): parse_s_pre,
qn("m:t"): parse_t,
qn("m:rad"): parse_rad,
qn("m:nary"): parse_nary,
qn("m:eqArr"): parse_eq_arr,
qn("m:func"): parse_func,
qn("m:m"): parse_m,
qn("m:mr"): parse_mr,
}
+40
View File
@@ -0,0 +1,40 @@
"""
Utility functions to extract text from the supported mathematical equations from xml tags and
convert them into LaTeX
"""
from .cleaners import clean_exp
ns_map = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
}
def linear_expression(tag):
"""
Just returns the text contained in the given tag while setting docxlatex_skip_iteration flags
for all its children.
:param tag:defusedxml.Element - An xml element which contains a math equation in linear form
:return text:str - The equation in valid LaTeX syntax
"""
text = ""
for child in tag.iter():
child.set("docxlatex_skip_iteration", True)
text += child.text if child.text is not None else ""
text = clean_exp(text)
return text
def qn(tag):
"""
A utility function to turn a namespace
prefixed tag name into a Clark-notation qualified tag name for lxml. For
example, qn('m:oMath') returns '{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath'
:param tag:str - A namespace-prefixed tag name
:return qn:str - A Clark-notation qualified name tag for lxml.
"""
prefix, tag_root = tag.split(":")
uri = ns_map[prefix]
return "{{{}}}{}".format(uri, tag_root)
+932
View File
@@ -0,0 +1,932 @@
#!/usr/bin/env python3
"""
ABOUTME: Parses DOCX documents into text blocks using python-docx
ABOUTME: Extracts automatic numbering, splits by headings, converts tables to JSON
"""
import json
import sys
try:
from docx import Document
from docx.opc.exceptions import PackageNotFoundError
except ImportError as exc:
# Raise instead of sys.exit: this module is imported in-process by the
# gunicorn/uvicorn worker, where a SystemExit would tear down the whole
# worker rather than surfacing a normal, catchable error.
raise ImportError(
"python-docx not installed. Run: pip install python-docx"
) from exc
from lightrag.parser._markdown import (
render_heading_line,
strip_heading_markdown_prefix,
)
from .numbering_resolver import NumberingResolver
from .table_extractor import TableExtractor
from .drawing_image_extractor import (
DrawingExtractionContext,
extract_drawing_placeholder_from_element,
extract_vml_image_placeholder_from_element,
)
# Constants for content validation (character-based for UI/display)
MAX_HEADING_LENGTH = 200 # Maximum heading length in characters (UI constraint)
# OOXML tracked-change/comment tags whose subtree must be dropped so we only
# surface the *final* revised text. w:ins / w:moveTo are kept via default
# recursion so inserted/moved-in content survives.
_SKIP_REVISION_TAGS = frozenset({"del", "moveFrom"})
_SKIP_COMMENT_TAGS = frozenset(
{"commentRangeStart", "commentRangeEnd", "commentReference", "annotationRef"}
)
_SKIP_PARAGRAPH_TAGS = _SKIP_REVISION_TAGS | _SKIP_COMMENT_TAGS
class DocxContentError(ValueError):
"""DOCX content violates a parsing constraint (heading/table/anchor limits).
Raised instead of calling ``sys.exit`` so the pipeline's per-document
``except Exception`` handler marks just that document FAILED while the
gunicorn/uvicorn worker process keeps running. Subclasses ``ValueError``
(i.e. an ``Exception``, not ``BaseException``) so the existing pipeline
handlers catch it.
"""
def format_error(title: str, details: str, solution: str) -> str:
"""
Build a friendly, formatted error message (title / details / SOLUTION).
Args:
title: Error title
details: Detailed error information
solution: Suggested solution steps
Returns:
str: The formatted multi-line message.
"""
return (
"\n"
+ "=" * 68
+ f"\nERROR: {title}\n"
+ "=" * 68
+ f"\n\n{details}"
+ "\n\nSOLUTION:\n"
+ solution
+ "\n\n"
+ "=" * 68
+ "\n"
)
def print_error(title: str, details: str, solution: str):
"""Print a friendly, formatted error message to stderr."""
print(format_error(title, details, solution), file=sys.stderr)
def _diagnose_invalid_docx(file_path: str) -> tuple[str, str]:
"""Diagnose why a ``.docx`` file is not a valid OOXML/ZIP package.
python-docx raises ``PackageNotFoundError("Package not found at '...'")``
both when the path is missing AND when the file exists but is not a valid
zip. By the time this runs the file has already been confirmed to exist
(the native worker validates ``p.exists()`` first), so the real cause is a
corrupt file or a non-DOCX payload wearing a ``.docx`` extension. Sniff the
magic bytes to name the actual format so the error message reflects the
real problem instead of an empty "not found".
Returns a ``(details, solution)`` tuple for :func:`format_error`. Reads only
the file header and never raises any IO failure degrades to a generic
"cannot read" diagnosis.
"""
import zipfile
convert_solution = (
" 1. Open the file in Microsoft Word or WPS\n"
' 2. Use "Save As" and choose "Word Document (*.docx)"\n'
" 3. Re-upload the converted .docx to LightRAG"
)
try:
with open(file_path, "rb") as f:
head = f.read(8)
except OSError as exc:
return (
f"The file at '{file_path}' could not be read: {exc}",
" 1. Verify the file exists and is readable\n"
" 2. Re-upload it to LightRAG",
)
if not head:
return (
f"The file at '{file_path}' is empty (0 bytes). The upload was "
"likely truncated or the source file is corrupt.",
" 1. Check the original document opens correctly\n"
" 2. Re-upload a complete copy to LightRAG",
)
if head.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
# OLE2 Compound File — the legacy binary Word 97-2003 .doc format.
return (
f"The file at '{file_path}' is a legacy Word 97-2003 (.doc) "
"document saved with a .docx extension. The .doc binary format is "
"not a ZIP/OOXML package and cannot be parsed by the native engine.",
convert_solution,
)
if head.startswith(b"{\\rtf"):
return (
f"The file at '{file_path}' is an RTF document saved with a .docx "
"extension. RTF is not a ZIP/OOXML package.",
convert_solution,
)
if head.startswith(b"%PDF"):
return (
f"The file at '{file_path}' is a PDF saved with a .docx extension. "
"It is not a ZIP/OOXML package.",
" 1. Convert the PDF to .docx, or upload it through a PDF-capable "
"parser engine (e.g. mineru/docling)\n"
" 2. Re-upload to LightRAG",
)
stripped = head.lstrip()
if stripped.startswith(b"<"):
# <?xml ...>, <html ...>, or Word 2003 "<w:wordDocument>" flat XML.
return (
f"The file at '{file_path}' is an HTML or XML document saved with a "
".docx extension, not a ZIP/OOXML package.",
convert_solution,
)
if head.startswith(b"PK\x03\x04") and not zipfile.is_zipfile(file_path):
# Has the ZIP local-file-header magic but the archive is unreadable.
return (
f"The file at '{file_path}' starts like a ZIP archive but is "
"truncated or corrupt, so it cannot be opened as a DOCX package.",
" 1. Check the original document opens correctly\n"
" 2. Re-upload a complete, uncorrupted copy to LightRAG",
)
return (
f"The file at '{file_path}' is not a valid DOCX (ZIP/OOXML) package. "
"It is either corrupt or a different file format saved with a .docx "
"extension.",
convert_solution,
)
def truncate_heading(heading_text: str, para_id: str = None) -> str:
"""
Truncate heading if it exceeds MAX_HEADING_LENGTH.
Args:
heading_text: The heading text to check
para_id: Optional paragraph ID for warning message
Returns:
str: Original heading if within limit, truncated heading with "..." if too long
"""
if len(heading_text) > MAX_HEADING_LENGTH:
truncated = heading_text[: MAX_HEADING_LENGTH - 3] + "..."
location = f" (para_id: {para_id})" if para_id else ""
print(
f"Warning: Heading truncated (length {len(heading_text)} > max {MAX_HEADING_LENGTH}){location}: "
f'"{truncated}"',
file=sys.stderr,
)
return truncated
return heading_text
def validate_heading_length(heading_text: str, para_id: str):
"""
Validate that heading length does not exceed MAX_HEADING_LENGTH.
Args:
heading_text: The heading text to validate
para_id: The paragraph ID for error reporting
Raises:
DocxContentError: if heading exceeds maximum length
"""
if len(heading_text) > MAX_HEADING_LENGTH:
preview = (
heading_text[:100] + "..." if len(heading_text) > 100 else heading_text
)
raise DocxContentError(
format_error(
f"Heading too long ({len(heading_text)} characters, max {MAX_HEADING_LENGTH})",
f"The following heading exceeds the maximum allowed length:\n\n{preview}\n\n"
f"Location(para_id): {para_id}\n"
f"Actual length: {len(heading_text)} characters",
" 1. Open the document in Microsoft Word\n"
f" 2. Shorten this heading to {MAX_HEADING_LENGTH} characters or less\n"
" 3. Re-upload it to LightRAG",
)
)
def find_first_valid_para_id(para_ids: list) -> str | None:
"""
Find the first valid paraId in a 2D array of paraIds.
Args:
para_ids: 2D list of paraIds from table cells
Returns:
First non-None paraId found, or None when every cell lacks a paraId.
Callers must tolerate ``None`` and treat it as a tracking gap rather
than a fatal error (legacy / non-Word docx authors omit ``w14:paraId``
attributes and we want to keep parsing).
"""
for row in para_ids:
for para_id in row:
if para_id:
return para_id
return None
def find_last_valid_para_id(para_ids: list) -> str | None:
"""
Find the last valid paraId in a 2D array of paraIds.
Returns the last non-None paraId, falling back to the first valid one
when reverse-iteration does not yield anything (single-paraId tables),
and finally ``None`` when every cell lacks a paraId.
"""
for row in reversed(para_ids):
for para_id in reversed(row):
if para_id:
return para_id
return find_first_valid_para_id(para_ids)
def _table_has_any_paraid(para_ids: list) -> bool:
"""True when at least one cell in the 2D paraId grid carries an id."""
return find_first_valid_para_id(para_ids) is not None
def extract_para_id(para_element) -> str:
"""
Extract w14:paraId attribute from paragraph element.
Args:
para_element: lxml paragraph element
Returns:
8-character hex paraId, or ``None`` when the paragraph carries no
``w14:paraId`` attribute (legacy / non-Word docx authors). Callers
propagate the ``None`` upward the LightRAG adapter counts these
and surfaces a single warning per document.
"""
return para_element.get(
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
)
def parse_styles_outline_levels(docx_path: str) -> dict:
"""
Parse styles.xml to extract outlineLvl definitions for each style,
following style inheritance chain (basedOn).
Args:
docx_path: Path to DOCX file
Returns:
dict: styleId -> outlineLvl (0-8 for headings, 9 for body text)
"""
import zipfile
try:
from defusedxml import ElementTree as ET
except ImportError:
from xml.etree import ElementTree as ET
styles_outline = {} # styleId -> outlineLvl (directly defined)
style_based_on = {} # styleId -> parent styleId
try:
with zipfile.ZipFile(docx_path, "r") as zf:
if "word/styles.xml" not in zf.namelist():
return styles_outline
tree = ET.parse(zf.open("word/styles.xml"))
root = tree.getroot()
ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
# First pass: collect outlineLvl and basedOn for all styles
for style in root.findall(f".//{{{ns}}}style"):
style_id = style.get(f"{{{ns}}}styleId")
if not style_id:
continue
# Check for basedOn (style inheritance)
based_on = style.find(f"{{{ns}}}basedOn")
if based_on is not None:
parent_id = based_on.get(f"{{{ns}}}val")
if parent_id:
style_based_on[style_id] = parent_id
# Check for outlineLvl in style's pPr
pPr = style.find(f"{{{ns}}}pPr")
if pPr is not None:
outline_lvl_elem = pPr.find(f"{{{ns}}}outlineLvl")
if outline_lvl_elem is not None:
level = int(outline_lvl_elem.get(f"{{{ns}}}val"))
styles_outline[style_id] = level
# Second pass: resolve inheritance chain for styles without direct outlineLvl
def get_outline_level(style_id: str, visited: set = None) -> int:
if visited is None:
visited = set()
if style_id in visited:
return None # Prevent circular references
visited.add(style_id)
# If this style directly defines outlineLvl, return it
if style_id in styles_outline:
return styles_outline[style_id]
# Otherwise check parent style
if style_id in style_based_on:
parent_id = style_based_on[style_id]
return get_outline_level(parent_id, visited)
return None
# Fill in missing outlineLvl from inheritance chain
all_style_ids = set(styles_outline.keys()) | set(style_based_on.keys())
for style_id in all_style_ids:
if style_id not in styles_outline:
level = get_outline_level(style_id)
if level is not None:
styles_outline[style_id] = level
except Exception:
# Silently ignore parsing errors
pass
return styles_outline
def get_heading_level(para_element, styles_outline_map: dict) -> int:
"""
Get heading level from paragraph, checking both direct format and style.
Priority: paragraph outlineLvl > style outlineLvl
Args:
para_element: lxml paragraph element
styles_outline_map: dict of styleId -> outlineLvl from styles.xml
Returns:
int: 0-8 for heading levels (0=level 1, 1=level 2, etc.), None for non-heading
"""
# 1. Check paragraph direct format
pPr = para_element.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr"
)
if pPr is not None:
outline_elem = pPr.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}outlineLvl"
)
if outline_elem is not None:
level = int(
outline_elem.get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
)
)
# Only 0-8 are true heading levels (9 is body text)
if level < 9:
return level
else:
return None # Level 9 is body text
# 2. Check style definition's outlineLvl
if pPr is not None:
pStyle_elem = pPr.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pStyle"
)
if pStyle_elem is not None:
style_id = pStyle_elem.get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
)
if style_id and style_id in styles_outline_map:
level = styles_outline_map[style_id]
if level < 9:
return level
else:
return None
return None
def extract_text_from_run(
run,
ns: dict,
drawing_context: DrawingExtractionContext = None,
) -> str:
"""
Extract text from a run element, preserving superscript/subscript with markup.
Converts Word formatting to HTML-like tags:
- Superscript: <sup>text</sup>
- Subscript: <sub>text</sub>
- Normal text: unchanged
Args:
run: lxml run element (w:r)
ns: XML namespace dictionary
Returns:
Text string with <sup>/<sub> markup for formatted portions
"""
text = ""
# Check for vertAlign in rPr (superscript/subscript)
vert_align = None
rPr = run.find("w:rPr", ns)
if rPr is not None:
vert_elem = rPr.find("w:vertAlign", ns)
if vert_elem is not None:
vert_align = vert_elem.get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
)
# Extract text content from run children
for child in run:
tag = child.tag.split("}")[-1] # Remove namespace
if tag == "t" and child.text:
text += child.text
elif tag == "tab":
text += "\t"
elif tag == "br":
# Handle line breaks - textWrapping or no type = soft line break
br_type = child.get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}type"
)
if br_type in (None, "textWrapping"):
text += "\n"
# Skip page and column breaks (layout elements)
elif tag == "drawing":
text += extract_drawing_placeholder_from_element(
child,
context=drawing_context,
include_extended_attrs=True,
)
elif tag in ("pict", "object"):
text += extract_vml_image_placeholder_from_element(
child,
context=drawing_context,
include_extended_attrs=True,
)
# Apply superscript/subscript markup if needed
if text and vert_align == "superscript":
return f"<sup>{text}</sup>"
elif text and vert_align == "subscript":
return f"<sub>{text}</sub>"
return text
def extract_paragraph_content(
element,
ns,
drawing_context: DrawingExtractionContext = None,
) -> str:
"""
Extract text and equations from a paragraph element in document order.
Handles w:r (text runs), m:oMath (inline equations), and m:oMathPara
(block equations). Recurses into container elements (e.g., w:hyperlink,
w:ins, w:sdt, w:fldSimple, w:smartTag) to avoid dropping content.
Args:
element: lxml paragraph element (w:p)
ns: XML namespace dictionary
Returns:
Text string with equations wrapped in <equation> tags
"""
parts = []
def append_from(node) -> None:
tag = node.tag.split("}")[-1]
# Drop tracked-change deletions (w:del/w:moveFrom) and comment markers
# (w:commentRangeStart/End, w:commentReference, w:annotationRef) so the
# output only contains the final revised text without annotation glyphs.
if tag in _SKIP_PARAGRAPH_TAGS:
return
if tag == "r":
parts.append(
extract_text_from_run(node, ns, drawing_context=drawing_context)
)
return
if tag == "oMath":
from .omml import convert_omml_to_latex
latex = convert_omml_to_latex(node)
if latex:
parts.append(f"<equation>{latex}</equation>")
return
if tag == "oMathPara":
from .omml import convert_omml_to_latex
for omath in node:
if omath.tag.split("}")[-1] == "oMath":
latex = convert_omml_to_latex(omath)
if latex:
parts.append(f"<equation>{latex}</equation>")
return
for child in node:
append_from(child)
for child in element:
append_from(child)
return "".join(parts)
def _is_table_empty(rows: list) -> bool:
"""Return True iff every cell in ``rows`` is whitespace-only."""
return all(not (cell or "").strip() for row in rows for cell in row)
def _collect_table_headers(paragraphs: list) -> list:
"""Collect per-table cross-page header rows from ``is_table`` paragraphs.
The returned list is aligned 1:1 with the order of ``<table>`` placeholder
tags emitted into the block's content; entries are either the list of
header rows captured from ``w:tblHeader`` or ``None`` when the table has
no cross-page repeating header.
"""
return [p.get("_table_header") for p in paragraphs if p.get("is_table")]
def _build_unsplit_block(
heading: str, paragraphs: list, parent_headings: list, level: int
) -> dict:
"""Build a single block from paragraphs without size-based splitting."""
last_para = paragraphs[-1]
block = {
"uuid": paragraphs[0]["para_id"],
"uuid_end": last_para.get("para_id_end") or last_para.get("para_id"),
"heading": heading,
"content": "\n".join(p["text"] for p in paragraphs),
"type": "text",
"parent_headings": parent_headings,
"level": level,
}
table_headers = _collect_table_headers(paragraphs)
if table_headers:
block["table_headers"] = table_headers
return block
def _flush_current_block(
blocks: list,
heading: str,
paragraphs: list,
parent_headings: list,
level: int,
) -> None:
"""Flush accumulated paragraphs into a single heading-scoped block.
The native parser performs only heading-driven structural splitting; block
sizing (long-block anchor splitting, table row splitting, small-block
merging) is the downstream paragraph-semantic chunker's responsibility.
"""
if not paragraphs:
return
blocks.append(_build_unsplit_block(heading, paragraphs, parent_headings, level))
def extract_docx_blocks(
file_path: str,
drawing_context: DrawingExtractionContext = None,
parse_warnings: dict | None = None,
parse_metadata: dict | None = None,
) -> list:
"""
Extract heading-scoped text blocks from a DOCX file.
Uses python-docx with a custom numbering resolver to:
1. Capture automatic numbering (list labels)
2. Split the document into one block per heading (structural splitting)
3. Convert tables to JSON (2D array) and emit them as <table> placeholders
4. Preserve superscript/subscript formatting with <sup>/<sub> markup
Block sizing long-block anchor splitting, table row splitting, and
small-block merging is intentionally NOT done here; it is the downstream
paragraph-semantic chunker's responsibility. Blocks emitted here may
therefore be arbitrarily large.
Args:
file_path: Path to the DOCX file
parse_warnings: Optional out-dict that this function mutates with
non-fatal warnings observed during parsing. Currently used for
``missing_paraid_count`` incremented once per body-level
paragraph (heading or text) that lacks a ``w14:paraId`` and once
per table whose every cell lacks one. Callers (the LightRAG
adapter / debug CLI) read this to surface a one-line warning per
document instead of crashing.
parse_metadata: Optional out-dict that this function mutates with
document-level metadata derived during parsing. Currently used
for ``first_heading`` the text of the first heading encountered
in document order (regardless of level). Used by the LightRAG
adapter to populate ``meta.doc_title`` in ``.blocks.jsonl``.
Returns:
List of block dictionaries with heading, content, type, and metadata
"""
try:
doc = Document(file_path)
except PackageNotFoundError as exc:
# python-docx surfaces a misleading "Package not found at '...'" for any
# file it cannot open as a ZIP/OOXML package — including files that
# exist but are corrupt or a different format wearing a .docx extension.
# Diagnose the real cause from the magic bytes and raise a DocxContentError
# (a ValueError) so the pipeline's per-document handler marks just this
# document FAILED with an accurate, actionable message.
details, solution = _diagnose_invalid_docx(file_path)
raise DocxContentError(
format_error("File is not a valid DOCX document", details, solution)
) from exc
resolver = NumberingResolver(file_path)
styles_outline = parse_styles_outline_levels(file_path)
blocks = []
current_heading = "Preface/Uncategorized"
current_heading_level = 1 # Default level for "Preface/Uncategorized"
current_heading_stack = {} # {level: heading_text} - Use dict to correctly track heading hierarchy
current_parent_headings = [] # Parent headings for current block
current_paragraphs = [] # Track paragraphs with metadata for splitting
first_heading_recorded = (
False # Track whether the document's first heading has been captured
)
# Iterate through document body elements (paragraphs and tables)
body = doc._element.body
for element in body:
tag = element.tag.split("}")[-1] # Remove namespace
if tag == "sectPr": # Document-level section break
resolver.reset_tracking_state()
continue
if tag == "p": # Paragraph
# Get paragraph text with superscript/subscript markup and equations
para_text = ""
ns = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
}
para_text = extract_paragraph_content(
element,
ns,
drawing_context=drawing_context,
)
para_text = para_text.strip()
if not para_text:
continue
# Get numbering label using our resolver
label = resolver.get_label(element)
full_text = f"{label} {para_text}".strip() if label else para_text
# Check if this is a heading using the new function
outline_level = get_heading_level(element, styles_outline)
# A "heading" longer than MAX_HEADING_LENGTH is not a real heading.
# The common cause (WPS/Word): the author set an outline level on a
# paragraph but typed the body with soft line breaks (Shift+Enter →
# <w:br/> → '\n') instead of starting a new paragraph, so heading
# text + body live in one <w:p>. Split at the first soft break: the
# first line stays the heading, the remainder becomes body text. If
# there is no usable soft break (a genuine single-line over-long
# heading), demote the whole paragraph to body text. Either way we
# avoid crashing via validate_heading_length() and never drop content.
demoted_body_text = None
if outline_level is not None and len(full_text) > MAX_HEADING_LENGTH:
head, sep, rest = full_text.partition("\n")
if sep and len(head) <= MAX_HEADING_LENGTH:
full_text = head
demoted_body_text = rest.strip() or None
if parse_warnings is not None:
parse_warnings["heading_softbreak_split_count"] = (
parse_warnings.get("heading_softbreak_split_count", 0) + 1
)
print(
f"Warning: heading paragraph exceeded {MAX_HEADING_LENGTH} "
"chars; split at soft line break — kept first line as "
"heading, rest as body.",
file=sys.stderr,
)
else:
outline_level = None
if parse_warnings is not None:
parse_warnings["demoted_oversize_heading_count"] = (
parse_warnings.get("demoted_oversize_heading_count", 0) + 1
)
print(
f"Warning: paragraph has outline level but is "
f"{len(full_text)} chars (> {MAX_HEADING_LENGTH}); treating "
"as body text, not a heading.",
file=sys.stderr,
)
if outline_level is not None:
# This is a heading (outline level 0-8)
# Convert 0-based to 1-based level
level = outline_level + 1
# Extract paraId for this heading
heading_para_id = extract_para_id(element)
if parse_warnings is not None and not heading_para_id:
parse_warnings["missing_paraid_count"] = (
parse_warnings.get("missing_paraid_count", 0) + 1
)
# Validate heading length
validate_heading_length(full_text, heading_para_id)
# Truncate heading if needed before storing
truncated_text = truncate_heading(full_text, heading_para_id)
clean_heading_text = strip_heading_markdown_prefix(truncated_text)
# Record the document's first heading (any level) for meta.doc_title.
if not first_heading_recorded:
if parse_metadata is not None:
parse_metadata["first_heading"] = clean_heading_text
first_heading_recorded = True
# Every recognized heading starts its own block. Always flush the
# accumulated paragraphs so a heading with no body becomes a
# standalone block whose content is just the heading text,
# instead of being folded into the next heading's block.
if current_paragraphs:
_flush_current_block(
blocks,
current_heading,
current_paragraphs,
current_parent_headings,
current_heading_level,
)
# Reset for new block
current_paragraphs = []
# Add heading to current_paragraphs. The content line gets
# a markdown ``#`` prefix (capped at 6) via
# render_heading_line; ``clean_heading_text`` is kept
# for the heading field / stack / parent_headings below.
current_paragraphs.append(
{
"text": render_heading_line(level, truncated_text),
"para_id": heading_para_id,
"is_table": False,
}
)
# Update current_heading and parent_headings for the FIRST heading in a block
# (when current_paragraphs just had this heading added as its first element)
if len(current_paragraphs) == 1:
current_heading = clean_heading_text
current_heading_level = level # Only set level when setting heading
# Parent headings = all headings from levels strictly less than current level
# Sort by level to maintain hierarchy order
current_parent_headings = [
current_heading_stack[lvl]
for lvl in sorted(current_heading_stack.keys())
if lvl < level
]
# Update heading stack: remove current level and all lower levels, then add current
current_heading_stack = {
k: v for k, v in current_heading_stack.items() if k < level
}
current_heading_stack[level] = clean_heading_text
# Carry the body text that followed a soft break in an over-long
# heading paragraph as a regular body paragraph in the same block.
if demoted_body_text:
current_paragraphs.append(
{
"text": demoted_body_text,
"para_id": heading_para_id,
"is_table": False,
}
)
else:
# Regular paragraph content
para_id = extract_para_id(element)
if parse_warnings is not None and not para_id:
parse_warnings["missing_paraid_count"] = (
parse_warnings.get("missing_paraid_count", 0) + 1
)
# Store paragraph with metadata for potential splitting
current_paragraphs.append(
{"text": full_text, "para_id": para_id, "is_table": False}
)
# Check for paragraph-level section break (after processing paragraph)
# sectPr in pPr means this paragraph ends a section
pPr = element.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr"
)
if pPr is not None:
sectPr = pPr.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}sectPr"
)
if sectPr is not None:
# Section break after this paragraph - reset tracking
resolver.reset_tracking_state()
elif tag == "tbl": # Table
# Reset numbering tracking before table (table start boundary)
resolver.reset_tracking_state()
# Directly create Table object from XML element to avoid index mismatch
# (doc.tables may have different order due to nested tables)
from docx.table import Table
table = Table(element, doc)
table_metadata = TableExtractor.extract_with_metadata(
table,
numbering_resolver=resolver,
drawing_context=drawing_context,
)
table_rows = table_metadata["rows"]
para_ids = table_metadata["para_ids"]
para_ids_end = table_metadata["para_ids_end"] # Last paraId in each cell
header_indices = table_metadata["header_indices"]
# Skip tables whose every cell is whitespace-only — otherwise an
# empty `<table>[[""]]</table>` placeholder would leak into block
# content and a useless IRTable would appear in tables.json.
if _is_table_empty(table_rows):
resolver.reset_tracking_state()
continue
# Count tables whose cells carry no w14:paraId. Legacy / non-Word
# docx authors omit these attributes; we no longer fail-fast, but
# the adapter surfaces a single warning so the user knows the edit
# range hints will be missing for these tables.
if parse_warnings is not None and not _table_has_any_paraid(para_ids):
parse_warnings["missing_paraid_count"] = (
parse_warnings.get("missing_paraid_count", 0) + 1
)
# Convert table to JSON
table_json = json.dumps(table_rows, ensure_ascii=False)
# Extract cross-page repeating header rows (w:tblHeader) once per
# table so we can surface them to the sidecar via the block-level
# ``table_headers`` list.
header_rows = []
if header_indices:
header_rows = [
table_rows[idx] for idx in header_indices if idx < len(table_rows)
]
header_rows_or_none = header_rows if header_rows else None
# Emit the whole table as a single <table> placeholder. Token-based
# table row splitting is the downstream chunker's responsibility.
# Use first valid paraId from table, and last valid paraId (from
# para_ids_end) for uuid_end.
table_para_id = find_first_valid_para_id(para_ids)
table_para_id_end = find_last_valid_para_id(para_ids_end)
current_paragraphs.append(
{
"text": f"<table>{table_json}</table>",
"para_id": table_para_id,
"para_id_end": table_para_id_end, # Store end paraId for uuid_end calculation
"is_table": True,
"_table_header": header_rows_or_none,
}
)
# Reset numbering tracking after table (table end boundary)
resolver.reset_tracking_state()
# Save final block
_flush_current_block(
blocks,
current_heading,
current_paragraphs,
current_parent_headings,
current_heading_level,
)
return blocks
+96
View File
@@ -0,0 +1,96 @@
"""Native DOCX engine adapter (implements NativeParserBase hooks)."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from lightrag.constants import PARSER_ENGINE_NATIVE
from lightrag.parser.native_base import NativeParserBase
from lightrag.utils import logger
if TYPE_CHECKING:
from lightrag.sidecar.ir import IRDoc
class NativeDocxParser(NativeParserBase):
"""Native DOCX parser for LightRAG's production parsing path.
``extract_docx_blocks`` performs only heading-driven structural splitting
(one block per DOCX heading). Block sizing is intentionally left to the
downstream paragraph-semantic chunker, so this parser emits the
one-heading-one-block sidecar contract that chunking consumes.
"""
engine_name = PARSER_ENGINE_NATIVE
sidecar_path_style = "basename_only" # legacy native docx convention
empty_content_label = "DOCX"
def validate_source(self, source: Path, file_path: str) -> None:
if not (
source.exists() and source.is_file() and source.suffix.lower() == ".docx"
):
raise ValueError(
f"Native parser does not support pending file: {file_path}"
)
def extract(
self, source: Path, *, parsed_dir: Path, asset_dir: Path, base_name: str
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]:
"""Extract heading-scoped DOCX blocks (sizing left to the chunker)."""
from lightrag.parser.docx.drawing_image_extractor import (
DrawingExtractionContext,
load_relationships,
)
from lightrag.parser.docx.parse_document import extract_docx_blocks
ctx = DrawingExtractionContext(
docx_path=source,
blocks_output_path=parsed_dir / f"{base_name}.blocks.jsonl",
export_dir_name=asset_dir.name,
export_dir_path=asset_dir,
)
load_relationships(ctx)
warnings: dict[str, Any] = {}
metadata: dict[str, Any] = {}
blocks = extract_docx_blocks(
str(source),
drawing_context=ctx,
parse_warnings=warnings,
parse_metadata=metadata,
)
return blocks, warnings, metadata
def build_ir(
self,
blocks: list[dict[str, Any]],
*,
document_name: str,
asset_dir_name: str,
metadata: dict[str, Any],
) -> "IRDoc":
from lightrag.parser.docx.ir_builder import NativeDocxIRBuilder
return NativeDocxIRBuilder().normalize(
blocks,
document_name=document_name,
asset_dir_name=asset_dir_name,
parse_metadata=metadata,
)
def surface_warnings(
self, warnings: dict[str, Any], source: Path
) -> dict[str, Any] | None:
missing = int(warnings.get("missing_paraid_count", 0) or 0)
if missing > 0:
# Surface once per document; affected blocks emit
# ``positions: [{"type": "paraid", "range": null}]``.
logger.warning(
"[parse_native] %s: %d paragraphs lack paraId; "
"Re-saving file in Word 2013+ to regenerate ids.",
source.name,
missing,
)
return {"missing_paraid_count": missing}
return None

Some files were not shown because too many files have changed in this diff Show More