{"owner":"HKUDS","repo":"LightRAG","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md"],"skills":{"AGENTS.md":"# Repository Guidelines\n\n## Project Overview\n\nLightRAG is a Retrieval-Augmented Generation (RAG) framework that uses graph-based knowledge representation for enhanced information retrieval. The system extracts entities and relationships from documents, builds a knowledge graph, and uses multiple retrieval modes (`local`, `global`, `hybrid`, `mix`, `naive`) for queries.\n\n## Project Structure\n\nTop-level directories:\n\n- **lightrag/**: Core Python package — see *Module Layout* below.\n- **lightrag_webui/**: React 19 + TypeScript client (Bun + Vite + Tailwind). UI components in `src/`.\n- **scripts/**: `test.sh` (preferred test runner), `setup/` interactive environment wizard (use `make env-*` rather than calling `setup.sh` directly — see *Configuration > Setup Wizard Outputs*), and release tooling.\n- **tests/**: Pytest coverage, organized into subdirectories that mirror `lightrag/` (see *Testing* below for layout). Working datasets stay in `inputs/`, `rag_storage/`, and `temp/`; deployment collateral lives in `docs/`, `k8s-deploy/`, and compose files.\n\n### Module Layout (`lightrag/`)\n\n- **lightrag.py**: Main orchestrator class (`LightRAG`) — assembled from mixins (see *LightRAG class composition*). Hosts `ainsert_custom_kg`, `_insert_done`, `_process_extract_entities`, `_refresh_addon_params_cache`, and `addon_params` accessors. Critical: always call `await rag.initialize_storages()` after instantiation.\n- **pipeline.py**: `_PipelineMixin` — owns the document ingestion pipeline (`apipeline_enqueue_documents`, `apipeline_process_enqueue_documents`, `apipeline_process_error_documents`), the `parse_native` / `parse_mineru` / `parse_docling` parser dispatchers, multimodal analysis, validation, and the worker scaffolding.\n- **utils_pipeline.py**: Pure helpers shared by the pipeline mixin and other entry points: doc-status field access, document identity (source key, content hash), parsed-artifact path resolution, parser payload normalization, multimodal entity augmentation, and `make_lightrag_doc_content`.\n- **llm_roles.py**: `RoleSpec` / `RoleLLMConfig` / `_RoleLLMState` / `ROLES` registry plus `_RoleLLMMixin` — role normalization, builder registration, wrapper rebuild, runtime config update, queue cleanup, sanitized config export, queue status reporting. Route role-specific behavior here rather than into provider modules.\n- **storage_migrations.py**: `_StorageMigrationMixin` — `check_and_migrate_data`, `_migrate_entity_relation_data`, `_migrate_chunk_tracking_storage`.\n- **addon_params.py**: `ObservableAddonParams` plus `default_addon_params` / `normalize_addon_params` helpers.\n- **operate.py**: Core extraction and query operations including entity/relation extraction, chunking, and multi-mode retrieval logic.\n- **base.py**: Abstract base classes for storage backends (`BaseKVStorage`, `BaseVectorStorage`, `BaseGraphStorage`, `BaseDocStatusStorage`).\n- **kg/**: Storage implementations (JSON, NetworkX, Neo4j, PostgreSQL, MongoDB, Redis, Milvus, Qdrant, Faiss, Memgraph, OpenSearch, NanoVectorDB). The backend registry (`STORAGE_IMPLEMENTATIONS` / `STORAGES`) lives in `kg/__init__.py`; `kg/factory.py::get_storage_class()` resolves backend classes from configuration.\n- **llm/**: LLM and embedding provider bindings (OpenAI, Ollama, Azure, Gemini, Bedrock, Anthropic, etc.). All async with caching support.\n- **parser/**: Unified parsing layer. `parser/routing.py` resolves engine and filename hints for `legacy`, `native`, `mineru`, and `docling` flows; `parser/debug.py` provides an offline LightRAG stub for the `parser/cli.py` debug entry point (`python -m lightrag.parser.cli`). Native format parsers live as sibling sub-packages under `parser/` (currently `parser/docx/`); external HTTP-based adapters live under `parser/external/` (`mineru`, `docling`) with shared helpers in `parser/external/_common.py`, `_manifest.py`, `_zip.py`.\n- **chunker/**: Chunking strategies (token-size, recursive character, semantic vector, paragraph semantic).\n- **api/**: FastAPI service (`lightrag_server.py`) with REST endpoints and Ollama-compatible API; routers under `routers/`, static Swagger assets, packaged WebUI output, and Gunicorn launcher.\n\n## Core Architecture\n\n### LightRAG class composition\n\n`LightRAG` is assembled from focused mixins (split out of the previously monolithic `lightrag.py`):\n\n```\nLightRAG → _RoleLLMMixin → _StorageMigrationMixin → _PipelineMixin → object\n```\n\nThe `@final` decorator on `LightRAG` is preserved — the mixin layering is an internal implementation detail, not an external subclassing surface. The public API (`ainsert`, `aquery`, `ainsert_custom_kg`, `initialize_storages`, etc.) is unchanged. `ainsert_custom_kg` and its internal construction logic, `_insert_done`, `_process_extract_entities`, `_refresh_addon_params_cache`, and the `addon_params` property accessors stay on `LightRAG` itself because they cut across multiple flows or depend on prompt-profile state.\n\n### Storage Layer\n\nLightRAG uses 4 storage types with pluggable backends:\n- **KV_STORAGE**: LLM response cache, text chunks, document info\n- **VECTOR_STORAGE**: Entity/relation/chunk embeddings\n- **GRAPH_STORAGE**: Entity-relation graph structure\n- **DOC_STATUS_STORAGE**: Document processing status tracking\n\nEach `LightRAG` instance can pass a `workspace` parameter for data isolation. Implementation differs per storage type:\n- **File-based**: subdirectories under `working_dir`.\n- **Collection-based**: collection name prefixes.\n- **Relational DB**: workspace column filtering.\n- **Qdrant**: payload-based partitioning.\n\n### Pipeline concurrency contract\n\nThe document ingestion pipeline coordinates concurrent writers through `pipeline_status` (a per-workspace shared dict in `lightrag.kg.shared_storage`). These fields are mutated under `get_namespace_lock(\"pipeline_status\", workspace=...)`:\n\n- **`busy`**: any pipeline-busy state. Set by both the processing loop AND destructive jobs (clear / per-doc delete). On its own, `busy=True` does NOT block enqueue — see `destructive_busy` for the exclusive subset.\n- **`destructive_busy`**: the busy job is `/documents/clear` or `/documents/{doc_id}` (delete). These DROP storages and remove input files; a concurrent enqueue accepted in this window would write to storage being torn down and silently lose the document. Reservation and the enqueue last-line guard reject when this is True.\n- **`scanning`**: a `/documents/scan` task is running (whole lifecycle: classification + processing). Used by the `/scan` endpoint to refuse overlapping scans. Does NOT on its own block uploads/inserts.\n- **`scanning_exclusive`**: True only during the scan task's classification phase, when `run_scanning_process` is reading `doc_status` to classify files (PROCESSED → archive, FAILED-without-`full_docs` → retry-as-new, etc.) and possibly deleting stale stubs. Reservation and the enqueue last-line guard reject when this is set. Cleared before the scan transitions to its processing phase, allowing concurrent uploads to land while scan-driven processing finishes.\n- **`pending_enqueues`**: count of `/upload`, `/text`, `/texts` endpoints that have reserved a slot (via `_reserve_enqueue_slot`) but whose bg task has not yet completed. Only the scan endpoint reads this — to refuse starting while uploads are mid-flight.\n\n**Workspace pipeline ingress** (`lightrag/kg/pipeline_ingress.py`, resolved via `get_pipeline_ingress(workspace)`): a three-channel mailbox living beside `pipeline_status` (never inside it — the status dict is serialized into API responses). It is the pipeline's only wake-up channel; `doc_status` stays the source of truth (a dropped notification is recovered by the next run's initial strict scan). Enqueue publishes document messages under `pipeline_status_lock` (one `put_documents` batch RPC); a busy-refused `apipeline_process_enqueue_documents` arms the **auto-rescan** flag inside `acquire_processing_reservation`'s own critical section. At every quiescence point the loop decides, atomically under `pipeline_status_lock`, cancellation first (consumes nothing), then: earliest sticky **manual retry** request (peeked, one per cycle) > **auto-rescan** dirty flag (consumed atomically; the loop is the sole consumer and re-arms it if the follow-up strict query fails) > **document** channel non-empty (peeked via `counts()`; resolved by a bounded drain-then-strict-scan refetch that compacts provably-stale messages) > release `busy` (same critical section).\n\n**FAILED retry semantics**: automatic runs resume only `_AUTO_RESUME_DOC_STATUSES` (PENDING + PROCESSING/PARSING/ANALYZING dead-process orphans). A FAILED document re-enters the pipeline exclusively through a sticky manual retry request published by `/documents/scan` (after its reservation is granted) or `/documents/reprocess_failed` (publish-first; pure storage-driven, no filesystem scan, no custom-chunk rollback). Each request grants at most ONE retry attempt (`_MANUAL_RETRY_DOC_STATUSES`, initial scan only) and is ACKed only after the FAILED→PENDING resets persist — a crash re-executes the request or leaves the docs PENDING for automatic recovery; a doc failing again stays FAILED until the next explicit request. All scheduling-control-plane `doc_status` queries use `get_docs_by_statuses(..., strict=True)` (complete-or-raise), and scheduler `full_docs` reads distinguish confirmed-absent (`None`) from backend errors (raise). Manual-intent endpoints start their work through `start_committed_background_task` (fence recheck + publish in one critical section; a post-commit cancellation never cancels the child).\n\nMutual-exclusion rules (all checked atomically inside the lock):\n\n| Operation | Refuses if | Writes |\n|---|---|---|\n| `_reserve_enqueue_slot` | `scanning_exclusive` or `destructive_busy` | `pending_enqueues++` |\n| `apipeline_enqueue_documents` (last-line guard) | (`scanning_exclusive` and not `from_scan`) or `destructive_busy` | — |\n| Scan endpoint reservation | `busy or scanning or pending_enqueues > 0` | `scanning = True` |\n| `apipeline_process_enqueue_documents` entry | (already busy → arm ingress auto-rescan, return) | `busy = True` (NOT `destructive_busy`) |\n| `clear_documents` / `delete_document` (synchronous reservation) | `busy or scanning or pending_enqueues > 0` | `busy = True`, `destructive_busy = True` |\n\nThe contract permits **concurrent enqueue + processing**: a freshly-uploaded doc lands in `doc_status` while the loop is mid-batch, its document message is routed into the running batch by the in-batch feeder (or resolved at the batch boundary by the quiescence decision), and the doc processes without waiting for a new run.\n\nFor the rest — write ordering of `full_docs` vs `doc_status`, the workspace-scoped `enqueue_serialize` lock around dedup-and-upsert, and the `from_scan=True` bypass — see the docstrings on `apipeline_enqueue_documents` and `apipeline_process_enqueue_documents` in `lightrag/pipeline.py`.\n\n### Purge recovery contract\n\nThe KG is shared across documents, so \"what did this document contribute?\" can only be answered from the per-document **write-ahead recovery anchors** (`full_entities` / `full_relations`, written and flushed in `merge_nodes_and_edges` Phase 0 *before* the first graph mutation). The reverse lookup — graph `source_id` → `text_chunks` → `full_doc_id` — is not a fallback, because purge deletes those chunks.\n\nThe governing invariant is narrower than \"every purge needs a proof\":\n\n> **A purge must never delete something that CARRIES attribution — a chunk row or an anchor row that names objects — and leave those objects behind.** An operation that removes no such carrier cannot strand anything and needs no proof.\n\n`_purge_kg_contributions` therefore **fails closed** (`RecoveryAnchorMissingError`, surfaced as HTTP 409, nothing deleted) when it would remove a carrier without one of these proofs. Treating absent anchors as an empty candidate list was issue #3400's silent-skip defect: graph cleanup was skipped while the chunks went anyway, stranding unattributable entities that `audit_kg_integrity` can only report as unrecoverable orphans.\n\n| Proof | Established by |\n|---|---|\n| `anchors` | Both anchor ROWS present and structurally usable. **Row presence is the test, never list truthiness** — an empty row is a document that extracted no entities, and conflating the two is the original bug. |\n| `pre_graph` | `doc_status.metadata.kg_write_state`. Stamped `pre_graph` at enqueue so every pre-merge failure state inherits it by carry-over; advanced to `graph_mutation_started` only by `merge_nodes_and_edges`' `on_anchors_durable` hook. **Monotonic** — nothing writes it back, because re-stamping `pre_graph` on reprocess would let the resume purge skip and orphan the previous run's contributions. Absent means UNKNOWN (pre-#3416), which fails closed. |\n| `journal` | `doc_status.metadata.kg_purge` at a phase past `prepared`, i.e. a previous attempt got far enough to have deleted the anchors itself. |\n| `empty_scope` | No chunks AND no anchor row that names anything — so the delete removes no carrier at all and the invariant is satisfied outright. This is what lets a row enqueued before the marker existed, still holding no chunks, be deleted directly (no scan, no audit). |\n\n**`kg_write_state` must never be inferred.** `pre_graph` asserts \"this document never touched the graph\", which licenses deleting its chunks while *skipping the graph* — sound only because the marker is written once, at enqueue, when it is necessarily true and the document has no history to misread. A backfill keying off a momentarily-empty `chunks_list` would stamp a document that does own graph objects, and because the stamp is durable the damage lands later, when the chunks reappear: chunks deleted, graph skipped, issue #3400 reproduced exactly. `empty_scope` is safe where such a backfill is not, because it is re-evaluated against live state on every call and grants nothing beyond that call. `tests/pipeline/test_purge_fail_closed.py::test_a_false_pre_graph_marker_would_reproduce_the_original_defect` pins the cost.\n\nAnchor-driven whole-document purge is **journaled and resumable** through four ordered phases — `prepared` → `derived_committed` → `anchors_pending` → `completed` — keyed by an operation id over the document key plus its chunk SET. The journal is *required by* fail-closed rather than an optimisation: purge's last step deletes the anchors, so without it any later failure would make every retry refuse forever. A resumed purge skips exactly the phases already persisted (so it never re-runs the LLM-cache-backed rebuild); an in-flight journal for a different operation is refused (`KGPurgeOperationConflictError`), while a stale `completed` one is ignored as dead bookkeeping.\n\nBoth metadata keys are in the `_DOC_STATUS_METADATA_CARRY_OVER_KEYS` **and** `_DOC_STATUS_METADATA_DIRECTIVE_KEYS` whitelists in `lightrag/utils_pipeline.py`; dropping either at a transition or a FAILED→PENDING reset turns a resumable purge into a permanent refusal. Retiring one requires `doc_status_transition_metadata(..., drop=...)` — passing it via `extra` would persist the value, and omitting it lets carry-over restore it.\n\nCallers: `adelete_by_doc_id` (delegates wholly to the primitive; the chunk-less branch runs it too), and the pipeline's resume path `_purge_stale_extraction_if_resuming` (which retires the journal and persists `chunks_list=[]` in one targeted write). Explicit-candidate mode — custom-chunk patch rollback — is neither journaled nor proof-checked, because its own operation journal already names the complete candidate superset; the primitive reads that journal to union in candidates no anchor row can name yet.\n\nA document can legitimately own nothing: `skip_kg` (`process_options` `'!'`) skips extraction and the merge, so no anchor rows are ever written. Post-change those documents carry `pre_graph` and delete normally; older ones have neither proof, and anchor repair has nothing to rebuild from.\n\n**Chunk tracking outranks graph `source_id`.** Within a surviving entity or relation, the `entity_chunks` / `relation_chunks` row is the authoritative chunk list; the graph node's `source_id` is only a truncated view of it (`apply_source_ids_limit`) and may legitimately still name chunks a previous purge already pruned — `_purge_kg_contributions` reads tracking first, falls back to `source_id` only when the row is absent, and its `graph_references_deleted_chunks` branch exists to repair exactly that lag. So code that folds a `source_id` delta back into tracking must append genuine additions only: restoring an ID that is in the graph but not in tracking writes stale attribution into the authoritative store, and a later purge would rebuild or retain KG objects from chunks that no longer exist. `compute_incremental_chunk_ids` carries this rule and `tests/utils/test_compute_incremental_chunk_ids.py` pins it. Genuinely missing attribution is repaired by `audit_kg_integrity`, never by the incremental path.\n\nThe offline remedy for a document with no proof is `audit_kg_integrity(..., apply=True)` (`lightrag/tools/kg_integrity_repair.py`): it rebuilds anchors from surviving chunk provenance, and — because it enumerates the **whole** graph, which the hot paths never do — it can additionally certify that a document appearing nowhere in that scan owns nothing, writing it the empty anchor rows that are the normal proof for such a document (`anchorless_docs` in the report). Absence is only ever concluded from the completed scan; a document that does own graph objects is repaired with its real names, never blanked.\n\n### Query Modes\n\n- **local**: Context-dependent retrieval focused on specific entities\n- **global**: Community/summary-based broad knowledge retrieval\n- **hybrid**: Combines local and global\n- **naive**: Direct vector search without graph\n- **mix**: Integrates KG and vector retrieval (recommended with reranker)\n\n## Development Commands\n\n### Setup\n```bash\n# Install with uv\nuv sync\nsource .venv/bin/activate  # Or: .venv\\Scripts\\activate on Windows\n\n# Install with API support\nuv sync --extra api\n\n# Install specific extras\nuv sync --extra offline-storage  # Storage backends\nuv sync --extra offline-llm      # LLM providers\nuv sync --extra test             # Testing dependencies\n```\n\n### API Server\n```bash\n# Copy and configure environment\ncp env.example .env  # Edit with your LLM/embedding configs\n\n# Build WebUI\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n\n# Run server\nlightrag-server                                           # Production\nuvicorn lightrag.api.lightrag_server:app --reload        # Development\nlightrag-gunicorn                                         # Multi-worker (gunicorn)\n```\n\n### WebUI\n```bash\ncd lightrag_webui\nbun install --frozen-lockfile      # Install dependencies\nbun run dev                        # Dev server (Node + Vite)\nbun run dev:bun                    # Dev server (Bun native)\nbun run build                      # Production build\nbun run preview                    # Preview production build\nbun run lint                       # ESLint over *.ts/tsx/js/jsx\n\n# Testing — Bun built-in runner (NOT Vitest/Jest)\nbun test                           # All tests\nbun test --watch                   # Watch mode\nbun test --coverage                # With coverage report\nbun test src/api/lightrag.test.ts  # Single test file\n```\n\n### Testing\n\n- Use mock-based tests for external services (Redis, httpx, etc.) — do not depend on live services in unit tests.\n- Add regression tests for every bug fix.\n- Run the full test suite (or relevant subset) and report pass counts before declaring done.\n- Backend tests use pytest; frontend unit tests use Bun's built-in runner — see *WebUI* above.\n\n```bash\n# Preferred for fresh shells and automation; resolves PYTHON, venv, uv, .venv, venv, python, python3\n./scripts/test.sh tests\n\n# Run specific test file\n./scripts/test.sh tests/kg/test_graph_storage.py\n\n# Run with custom workers\n./scripts/test.sh tests --test-workers 4\n```\n\n- `tests/`: main test suite, mirrors feature folders. Place new tests under the subdirectory matching the module under test:\n  - `tests/api/{auth,config,routes}/` for FastAPI server tests (auth/token, config loading, route handlers); top-level `tests/api/` for app-wide concerns (path prefixes, Ollama-compatible endpoint).\n  - `tests/chunker/`, `tests/evaluation/`, `tests/extraction/` for the like-named modules.\n  - `tests/kg/<backend>_impl/` for backend-specific storage tests, mirroring the `lightrag/kg/<backend>_impl.py` file naming. The `_impl` suffix on every subdirectory keeps the layout uniform and avoids `sys.path` shadowing on names that overlap with top-level PyPI/stdlib packages (`faiss`, `json`, `neo4j`, `networkx`, `redis`) when a test is launched directly via `python tests/kg/...`. Current backends: `faiss_impl/`, `json_impl/`, `memgraph_impl/`, `milvus_impl/`, `mongo_impl/`, `nano_impl/`, `neo4j_impl/`, `networkx_impl/`, `opensearch_impl/`, `postgres_impl/`, `qdrant_impl/`, `redis_impl/`. `tests/kg/` root holds cross-backend tests (`test_graph_storage`, `test_batch_graph_operations`, `test_unified_lock_safety`, `test_file_atomic`).\n  - `tests/llm/<provider>_impl/` for provider-specific behavior, same `_impl` convention: `bedrock_impl/`, `gemini_impl/`, `ollama_impl/`, `openai_impl/`, `voyageai_impl/`, `zhipu_impl/`. `tests/llm/` root holds cross-provider concerns (embedding, VLM, cache, role).\n  - `tests/parser/`, `tests/parser/docx/`, `tests/parser/external/{mineru,docling}/` for parser implementations.\n  - `tests/pipeline/` for ingestion pipeline and doc-status behavior (including `test_pipeline_*`, `test_doc_status_*`, `test_multimodal_*`, `test_graph_keyed_locks`).\n  - `tests/sidecar/`, `tests/setup/`, `tests/workspace/` for the like-named cross-cutting concerns.\n  - When adding a new backend or LLM provider, create a new subdirectory plus an empty `__init__.py` rather than dropping the file in the parent directory root.\n- Markers (registered in `[tool.pytest.ini_options]` in `pyproject.toml`): `offline`, `integration`, `requires_db`, `requires_api`, `pg_smoke`. Integration tests are skipped by default via `-m \"not integration\"`; opt in with `--run-integration`.\n- Integration env vars: `LIGHTRAG_RUN_INTEGRATION=true`, `LIGHTRAG_KEEP_ARTIFACTS=true`, `LIGHTRAG_TEST_WORKERS=4`, plus storage-specific connection strings.\n\n### Linting\n```bash\nruff check .\n```\n\n## Key Implementation Patterns\n\n### LightRAG Initialization (Critical)\n\nThe most common error is forgetting to initialize storages (manifests as `AttributeError: __aenter__` or `KeyError: 'history_messages'`):\n\n```python\nimport asyncio\nfrom lightrag import LightRAG\nfrom lightrag.llm.openai import gpt_4o_mini_complete, openai_embed\n\nasync def main():\n    rag = LightRAG(\n        working_dir=\"./rag_storage\",\n        llm_model_func=gpt_4o_mini_complete,\n        embedding_func=openai_embed\n    )\n\n    # REQUIRED: Initialize storage backends\n    await rag.initialize_storages()\n\n    # Now safe to use\n    await rag.ainsert(\"Your text here\")\n    result = await rag.aquery(\"Your question\", param=QueryParam(mode=\"hybrid\"))\n\n    # Cleanup\n    await rag.finalize_storages()\n\nasyncio.run(main())\n```\n\n### Custom Embedding Functions\n\nUse `@wrap_embedding_func_with_attrs` decorator and call `.func` when wrapping (already-decorated functions cannot be wrapped again — access the underlying via `.func`):\n\n```python\nfrom lightrag.utils import wrap_embedding_func_with_attrs\n\n@wrap_embedding_func_with_attrs(embedding_dim=1536, max_token_size=8192)\nasync def custom_embed(texts: list[str]) -> np.ndarray:\n    # Call underlying function, not wrapped version\n    return await openai_embed.func(texts, model=\"text-embedding-3-large\")\n\n# Wrong: EmbeddingFunc(func=openai_embed)\n# Right: EmbeddingFunc(func=openai_embed.func)\n```\n\n> **Pitfall — switching embedding models**: when changing the embedding model you MUST clear the data directory (optionally keeping `kv_store_llm_response_cache.json` for LLM cache). Existing vectors will not match the new model's space.\n\n### Storage Configuration\n\nConfigure via environment variables or constructor params:\n\n```python\n# Environment-based (recommended for production)\n# See env.example for full list\n\n# Constructor-based\nrag = LightRAG(\n    working_dir=\"./storage\",\n    workspace=\"project_name\",  # For data isolation\n    kv_storage=\"PGKVStorage\",\n    vector_storage=\"PGVectorStorage\",\n    graph_storage=\"Neo4JStorage\",\n    doc_status_storage=\"PGDocStatusStorage\",\n    vector_db_storage_cls_kwargs={\n        \"cosine_better_than_threshold\": 0.2\n    }\n)\n```\n\n### Document Insertion\n\n```python\n# Single document\nawait rag.ainsert(\"Text content\")\n\n# Batch insertion\nawait rag.ainsert([\"Text 1\", \"Text 2\", ...])\n\n# With custom IDs\nawait rag.ainsert(\"Text\", ids=[\"doc-123\"])\n\n# With file paths (for citation)\nawait rag.ainsert([\"Text 1\", \"Text 2\"], file_paths=[\"doc1.pdf\", \"doc2.pdf\"])\n\n# Configure batch size\nrag = LightRAG(..., max_parallel_insert=4)  # Default: 3, max recommended: 10\n```\n\n### Query Configuration\n\n```python\nfrom lightrag import QueryParam\n\nresult = await rag.aquery(\n    \"Your question\",\n    param=QueryParam(\n        mode=\"mix\",                    # Recommended with reranker\n        top_k=60,                      # KG entities/relations to retrieve\n        chunk_top_k=20,                # Text chunks to retrieve\n        max_entity_tokens=6000,\n        max_relation_tokens=8000,\n        max_total_tokens=30000,\n        enable_rerank=True,\n        user_prompt=\"Additional instructions for LLM\",\n        stream=False\n    )\n)\n```\n\n## Frontend Debugging via Playwright\n\nFor WebUI bugs whose symptoms only surface in the rendered DOM — layout/overflow/scrollbar issues, transient flashes, third-party libraries attaching helpers to `<body>` outside React's tree, or end-to-end verification of a fix — drive the running dev server (`http://localhost:5173`) with the `document-skills:webapp-testing` skill instead of reasoning from source alone. Seed state directly via `localStorage` (persist key `settings-storage`, schema in `lightrag_webui/src/stores/settings.ts`) to skip live LLM calls. Use `wait_until=\"domcontentloaded\"` plus a selector wait — Vite dev's long-lived polling makes `networkidle` time out.\n\n## Configuration\n\n### .env Configuration\nPrimary configuration file for API server. Generate it with `make env-base` or copy `env.example` manually. Key sections:\n- Server settings (HOST, PORT, CORS)\n- Storage backends (connection strings via environment variables)\n- Query parameters (TOP_K, MAX_TOTAL_TOKENS, etc.)\n- Reranking configuration (RERANK_BINDING, RERANK_MODEL)\n- Authentication (AUTH_ACCOUNTS, LIGHTRAG_API_KEY)\n\nSee `env.example` for comprehensive template.\n\n### Setup Wizard Outputs\n- Keep `.env` host-usable. Container-only hostnames and staged SSL paths belong in the wizard-managed compose layer, not persisted back into `.env`.\n- Treat `docker-compose.final.yml` as generated output assembled from `scripts/setup/templates/*.yml`.\n- For setup workflow changes, prefer `make env-*` targets over direct `scripts/setup/setup.sh` calls.\n\n## Code Style\n\n### Language\nComments, backend code, log messages, and Git commit messages in English. Frontend uses i18next for multi-language support.\n\n### Python\n- Follow PEP 8 with 4-space indentation\n- Use type annotations\n- Prefer dataclasses for state management\n- Use `lightrag.utils.logger` instead of print\n- Async/await patterns throughout\n\n### TypeScript / React (incl. WebUI ESLint)\n- Functional components with hooks; PascalCase for components\n- 2-space indentation, single quotes (enforced by `@stylistic` rules)\n- Tailwind utility-first styling\n- ESLint stack: TypeScript-ESLint + React Hooks plugin + Prettier; `@typescript-eslint/no-explicit-any` is disabled (allowed)\n\n## Commit and Pull Request Guidance\n\n- If this repo is a fork of `HKUDS/LightRAG`. Target to `HKUDS/LightRAG` when creating PRs, not the fork's own repo.\n- PR descriptions should include: summary, motivation, linked issues if applyed, what's changed, what's broken and how it works.\n- Write commit messages (subject and body) in English. Commit messages are repository artifacts — like code comments and log messages — not conversational replies, so they follow the English code-style rule above regardless of any per-conversation working language.\n"},"files":{"AGENTS.md":"# Repository Guidelines\n\n## Project Overview\n\nLightRAG is a Retrieval-Augmented Generation (RAG) framework that uses graph-based knowledge representation for enhanced information retrieval. The system extracts entities and relationships from documents, builds a knowledge graph, and uses multiple retrieval modes (`local`, `global`, `hybrid`, `mix`, `naive`) for queries.\n\n## Project Structure\n\nTop-level directories:\n\n- **lightrag/**: Core Python package — see *Module Layout* below.\n- **lightrag_webui/**: React 19 + TypeScript client (Bun + Vite + Tailwind). UI components in `src/`.\n- **scripts/**: `test.sh` (preferred test runner), `setup/` interactive environment wizard (use `make env-*` rather than calling `setup.sh` directly — see *Configuration > Setup Wizard Outputs*), and release tooling.\n- **tests/**: Pytest coverage, organized into subdirectories that mirror `lightrag/` (see *Testing* below for layout). Working datasets stay in `inputs/`, `rag_storage/`, and `temp/`; deployment collateral lives in `docs/`, `k8s-deploy/`, and compose files.\n\n### Module Layout (`lightrag/`)\n\n- **lightrag.py**: Main orchestrator class (`LightRAG`) — assembled from mixins (see *LightRAG class composition*). Hosts `ainsert_custom_kg`, `_insert_done`, `_process_extract_entities`, `_refresh_addon_params_cache`, and `addon_params` accessors. Critical: always call `await rag.initialize_storages()` after instantiation.\n- **pipeline.py**: `_PipelineMixin` — owns the document ingestion pipeline (`apipeline_enqueue_documents`, `apipeline_process_enqueue_documents`, `apipeline_process_error_documents`), the `parse_native` / `parse_mineru` / `parse_docling` parser dispatchers, multimodal analysis, validation, and the worker scaffolding.\n- **utils_pipeline.py**: Pure helpers shared by the pipeline mixin and other entry points: doc-status field access, document identity (source key, content hash), parsed-artifact path resolution, parser payload normalization, multimodal entity augmentation, and `make_lightrag_doc_content`.\n- **llm_roles.py**: `RoleSpec` / `RoleLLMConfig` / `_RoleLLMState` / `ROLES` registry plus `_RoleLLMMixin` — role normalization, builder registration, wrapper rebuild, runtime config update, queue cleanup, sanitized config export, queue status reporting. Route role-specific behavior here rather than into provider modules.\n- **storage_migrations.py**: `_StorageMigrationMixin` — `check_and_migrate_data`, `_migrate_entity_relation_data`, `_migrate_chunk_tracking_storage`.\n- **addon_params.py**: `ObservableAddonParams` plus `default_addon_params` / `normalize_addon_params` helpers.\n- **operate.py**: Core extraction and query operations including entity/relation extraction, chunking, and multi-mode retrieval logic.\n- **base.py**: Abstract base classes for storage backends (`BaseKVStorage`, `BaseVectorStorage`, `BaseGraphStorage`, `BaseDocStatusStorage`).\n- **kg/**: Storage implementations (JSON, NetworkX, Neo4j, PostgreSQL, MongoDB, Redis, Milvus, Qdrant, Faiss, Memgraph, OpenSearch, NanoVectorDB). The backend registry (`STORAGE_IMPLEMENTATIONS` / `STORAGES`) lives in `kg/__init__.py`; `kg/factory.py::get_storage_class()` resolves backend classes from configuration.\n- **llm/**: LLM and embedding provider bindings (OpenAI, Ollama, Azure, Gemini, Bedrock, Anthropic, etc.). All async with caching support.\n- **parser/**: Unified parsing layer. `parser/routing.py` resolves engine and filename hints for `legacy`, `native`, `mineru`, and `docling` flows; `parser/debug.py` provides an offline LightRAG stub for the `parser/cli.py` debug entry point (`python -m lightrag.parser.cli`). Native format parsers live as sibling sub-packages under `parser/` (currently `parser/docx/`); external HTTP-based adapters live under `parser/external/` (`mineru`, `docling`) with shared helpers in `parser/external/_common.py`, `_manifest.py`, `_zip.py`.\n- **chunker/**: Chunking strategies (token-size, recursive character, semantic vector, paragraph semantic).\n- **api/**: FastAPI service (`lightrag_server.py`) with REST endpoints and Ollama-compatible API; routers under `routers/`, static Swagger assets, packaged WebUI output, and Gunicorn launcher.\n\n## Core Architecture\n\n### LightRAG class composition\n\n`LightRAG` is assembled from focused mixins (split out of the previously monolithic `lightrag.py`):\n\n```\nLightRAG → _RoleLLMMixin → _StorageMigrationMixin → _PipelineMixin → object\n```\n\nThe `@final` decorator on `LightRAG` is preserved — the mixin layering is an internal implementation detail, not an external subclassing surface. The public API (`ainsert`, `aquery`, `ainsert_custom_kg`, `initialize_storages`, etc.) is unchanged. `ainsert_custom_kg` and its internal construction logic, `_insert_done`, `_process_extract_entities`, `_refresh_addon_params_cache`, and the `addon_params` property accessors stay on `LightRAG` itself because they cut across multiple flows or depend on prompt-profile state.\n\n### Storage Layer\n\nLightRAG uses 4 storage types with pluggable backends:\n- **KV_STORAGE**: LLM response cache, text chunks, document info\n- **VECTOR_STORAGE**: Entity/relation/chunk embeddings\n- **GRAPH_STORAGE**: Entity-relation graph structure\n- **DOC_STATUS_STORAGE**: Document processing status tracking\n\nEach `LightRAG` instance can pass a `workspace` parameter for data isolation. Implementation differs per storage type:\n- **File-based**: subdirectories under `working_dir`.\n- **Collection-based**: collection name prefixes.\n- **Relational DB**: workspace column filtering.\n- **Qdrant**: payload-based partitioning.\n\n### Pipeline concurrency contract\n\nThe document ingestion pipeline coordinates concurrent writers through `pipeline_status` (a per-workspace shared dict in `lightrag.kg.shared_storage`). These fields are mutated under `get_namespace_lock(\"pipeline_status\", workspace=...)`:\n\n- **`busy`**: any pipeline-busy state. Set by both the processing loop AND destructive jobs (clear / per-doc delete). On its own, `busy=True` does NOT block enqueue — see `destructive_busy` for the exclusive subset.\n- **`destructive_busy`**: the busy job is `/documents/clear` or `/documents/{doc_id}` (delete). These DROP storages and remove input files; a concurrent enqueue accepted in this window would write to storage being torn down and silently lose the document. Reservation and the enqueue last-line guard reject when this is True.\n- **`scanning`**: a `/documents/scan` task is running (whole lifecycle: classification + processing). Used by the `/scan` endpoint to refuse overlapping scans. Does NOT on its own block uploads/inserts.\n- **`scanning_exclusive`**: True only during the scan task's classification phase, when `run_scanning_process` is reading `doc_status` to classify files (PROCESSED → archive, FAILED-without-`full_docs` → retry-as-new, etc.) and possibly deleting stale stubs. Reservation and the enqueue last-line guard reject when this is set. Cleared before the scan transitions to its processing phase, allowing concurrent uploads to land while scan-driven processing finishes.\n- **`pending_enqueues`**: count of `/upload`, `/text`, `/texts` endpoints that have reserved a slot (via `_reserve_enqueue_slot`) but whose bg task has not yet completed. Only the scan endpoint reads this — to refuse starting while uploads are mid-flight.\n\n**Workspace pipeline ingress** (`lightrag/kg/pipeline_ingress.py`, resolved via `get_pipeline_ingress(workspace)`): a three-channel mailbox living beside `pipeline_status` (never inside it — the status dict is serialized into API responses). It is the pipeline's only wake-up channel; `doc_status` stays the source of truth (a dropped notification is recovered by the next run's initial strict scan). Enqueue publishes document messages under `pipeline_status_lock` (one `put_documents` batch RPC); a busy-refused `apipeline_process_enqueue_documents` arms the **auto-rescan** flag inside `acquire_processing_reservation`'s own critical section. At every quiescence point the loop decides, atomically under `pipeline_status_lock`, cancellation first (consumes nothing), then: earliest sticky **manual retry** request (peeked, one per cycle) > **auto-rescan** dirty flag (consumed atomically; the loop is the sole consumer and re-arms it if the follow-up strict query fails) > **document** channel non-empty (peeked via `counts()`; resolved by a bounded drain-then-strict-scan refetch that compacts provably-stale messages) > release `busy` (same critical section).\n\n**FAILED retry semantics**: automatic runs resume only `_AUTO_RESUME_DOC_STATUSES` (PENDING + PROCESSING/PARSING/ANALYZING dead-process orphans). A FAILED document re-enters the pipeline exclusively through a sticky manual retry request published by `/documents/scan` (after its reservation is granted) or `/documents/reprocess_failed` (publish-first; pure storage-driven, no filesystem scan, no custom-chunk rollback). Each request grants at most ONE retry attempt (`_MANUAL_RETRY_DOC_STATUSES`, initial scan only) and is ACKed only after the FAILED→PENDING resets persist — a crash re-executes the request or leaves the docs PENDING for automatic recovery; a doc failing again stays FAILED until the next explicit request. All scheduling-control-plane `doc_status` queries use `get_docs_by_statuses(..., strict=True)` (complete-or-raise), and scheduler `full_docs` reads distinguish confirmed-absent (`None`) from backend errors (raise). Manual-intent endpoints start their work through `start_committed_background_task` (fence recheck + publish in one critical section; a post-commit cancellation never cancels the child).\n\nMutual-exclusion rules (all checked atomically inside the lock):\n\n| Operation | Refuses if | Writes |\n|---|---|---|\n| `_reserve_enqueue_slot` | `scanning_exclusive` or `destructive_busy` | `pending_enqueues++` |\n| `apipeline_enqueue_documents` (last-line guard) | (`scanning_exclusive` and not `from_scan`) or `destructive_busy` | — |\n| Scan endpoint reservation | `busy or scanning or pending_enqueues > 0` | `scanning = True` |\n| `apipeline_process_enqueue_documents` entry | (already busy → arm ingress auto-rescan, return) | `busy = True` (NOT `destructive_busy`) |\n| `clear_documents` / `delete_document` (synchronous reservation) | `busy or scanning or pending_enqueues > 0` | `busy = True`, `destructive_busy = True` |\n\nThe contract permits **concurrent enqueue + processing**: a freshly-uploaded doc lands in `doc_status` while the loop is mid-batch, its document message is routed into the running batch by the in-batch feeder (or resolved at the batch boundary by the quiescence decision), and the doc processes without waiting for a new run.\n\nFor the rest — write ordering of `full_docs` vs `doc_status`, the workspace-scoped `enqueue_serialize` lock around dedup-and-upsert, and the `from_scan=True` bypass — see the docstrings on `apipeline_enqueue_documents` and `apipeline_process_enqueue_documents` in `lightrag/pipeline.py`.\n\n### Purge recovery contract\n\nThe KG is shared across documents, so \"what did this document contribute?\" can only be answered from the per-document **write-ahead recovery anchors** (`full_entities` / `full_relations`, written and flushed in `merge_nodes_and_edges` Phase 0 *before* the first graph mutation). The reverse lookup — graph `source_id` → `text_chunks` → `full_doc_id` — is not a fallback, because purge deletes those chunks.\n\nThe governing invariant is narrower than \"every purge needs a proof\":\n\n> **A purge must never delete something that CARRIES attribution — a chunk row or an anchor row that names objects — and leave those objects behind.** An operation that removes no such carrier cannot strand anything and needs no proof.\n\n`_purge_kg_contributions` therefore **fails closed** (`RecoveryAnchorMissingError`, surfaced as HTTP 409, nothing deleted) when it would remove a carrier without one of these proofs. Treating absent anchors as an empty candidate list was issue #3400's silent-skip defect: graph cleanup was skipped while the chunks went anyway, stranding unattributable entities that `audit_kg_integrity` can only report as unrecoverable orphans.\n\n| Proof | Established by |\n|---|---|\n| `anchors` | Both anchor ROWS present and structurally usable. **Row presence is the test, never list truthiness** — an empty row is a document that extracted no entities, and conflating the two is the original bug. |\n| `pre_graph` | `doc_status.metadata.kg_write_state`. Stamped `pre_graph` at enqueue so every pre-merge failure state inherits it by carry-over; advanced to `graph_mutation_started` only by `merge_nodes_and_edges`' `on_anchors_durable` hook. **Monotonic** — nothing writes it back, because re-stamping `pre_graph` on reprocess would let the resume purge skip and orphan the previous run's contributions. Absent means UNKNOWN (pre-#3416), which fails closed. |\n| `journal` | `doc_status.metadata.kg_purge` at a phase past `prepared`, i.e. a previous attempt got far enough to have deleted the anchors itself. |\n| `empty_scope` | No chunks AND no anchor row that names anything — so the delete removes no carrier at all and the invariant is satisfied outright. This is what lets a row enqueued before the marker existed, still holding no chunks, be deleted directly (no scan, no audit). |\n\n**`kg_write_state` must never be inferred.** `pre_graph` asserts \"this document never touched the graph\", which licenses deleting its chunks while *skipping the graph* — sound only because the marker is written once, at enqueue, when it is necessarily true and the document has no history to misread. A backfill keying off a momentarily-empty `chunks_list` would stamp a document that does own graph objects, and because the stamp is durable the damage lands later, when the chunks reappear: chunks deleted, graph skipped, issue #3400 reproduced exactly. `empty_scope` is safe where such a backfill is not, because it is re-evaluated against live state on every call and grants nothing beyond that call. `tests/pipeline/test_purge_fail_closed.py::test_a_false_pre_graph_marker_would_reproduce_the_original_defect` pins the cost.\n\nAnchor-driven whole-document purge is **journaled and resumable** through four ordered phases — `prepared` → `derived_committed` → `anchors_pending` → `completed` — keyed by an operation id over the document key plus its chunk SET. The journal is *required by* fail-closed rather than an optimisation: purge's last step deletes the anchors, so without it any later failure would make every retry refuse forever. A resumed purge skips exactly the phases already persisted (so it never re-runs the LLM-cache-backed rebuild); an in-flight journal for a different operation is refused (`KGPurgeOperationConflictError`), while a stale `completed` one is ignored as dead bookkeeping.\n\nBoth metadata keys are in the `_DOC_STATUS_METADATA_CARRY_OVER_KEYS` **and** `_DOC_STATUS_METADATA_DIRECTIVE_KEYS` whitelists in `lightrag/utils_pipeline.py`; dropping either at a transition or a FAILED→PENDING reset turns a resumable purge into a permanent refusal. Retiring one requires `doc_status_transition_metadata(..., drop=...)` — passing it via `extra` would persist the value, and omitting it lets carry-over restore it.\n\nCallers: `adelete_by_doc_id` (delegates wholly to the primitive; the chunk-less branch runs it too), and the pipeline's resume path `_purge_stale_extraction_if_resuming` (which retires the journal and persists `chunks_list=[]` in one targeted write). Explicit-candidate mode — custom-chunk patch rollback — is neither journaled nor proof-checked, because its own operation journal already names the complete candidate superset; the primitive reads that journal to union in candidates no anchor row can name yet.\n\nA document can legitimately own nothing: `skip_kg` (`process_options` `'!'`) skips extraction and the merge, so no anchor rows are ever written. Post-change those documents carry `pre_graph` and delete normally; older ones have neither proof, and anchor repair has nothing to rebuild from.\n\n**Chunk tracking outranks graph `source_id`.** Within a surviving entity or relation, the `entity_chunks` / `relation_chunks` row is the authoritative chunk list; the graph node's `source_id` is only a truncated view of it (`apply_source_ids_limit`) and may legitimately still name chunks a previous purge already pruned — `_purge_kg_contributions` reads tracking first, falls back to `source_id` only when the row is absent, and its `graph_references_deleted_chunks` branch exists to repair exactly that lag. So code that folds a `source_id` delta back into tracking must append genuine additions only: restoring an ID that is in the graph but not in tracking writes stale attribution into the authoritative store, and a later purge would rebuild or retain KG objects from chunks that no longer exist. `compute_incremental_chunk_ids` carries this rule and `tests/utils/test_compute_incremental_chunk_ids.py` pins it. Genuinely missing attribution is repaired by `audit_kg_integrity`, never by the incremental path.\n\nThe offline remedy for a document with no proof is `audit_kg_integrity(..., apply=True)` (`lightrag/tools/kg_integrity_repair.py`): it rebuilds anchors from surviving chunk provenance, and — because it enumerates the **whole** graph, which the hot paths never do — it can additionally certify that a document appearing nowhere in that scan owns nothing, writing it the empty anchor rows that are the normal proof for such a document (`anchorless_docs` in the report). Absence is only ever concluded from the completed scan; a document that does own graph objects is repaired with its real names, never blanked.\n\n### Query Modes\n\n- **local**: Context-dependent retrieval focused on specific entities\n- **global**: Community/summary-based broad knowledge retrieval\n- **hybrid**: Combines local and global\n- **naive**: Direct vector search without graph\n- **mix**: Integrates KG and vector retrieval (recommended with reranker)\n\n## Development Commands\n\n### Setup\n```bash\n# Install with uv\nuv sync\nsource .venv/bin/activate  # Or: .venv\\Scripts\\activate on Windows\n\n# Install with API support\nuv sync --extra api\n\n# Install specific extras\nuv sync --extra offline-storage  # Storage backends\nuv sync --extra offline-llm      # LLM providers\nuv sync --extra test             # Testing dependencies\n```\n\n### API Server\n```bash\n# Copy and configure environment\ncp env.example .env  # Edit with your LLM/embedding configs\n\n# Build WebUI\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n\n# Run server\nlightrag-server                                           # Production\nuvicorn lightrag.api.lightrag_server:app --reload        # Development\nlightrag-gunicorn                                         # Multi-worker (gunicorn)\n```\n\n### WebUI\n```bash\ncd lightrag_webui\nbun install --frozen-lockfile      # Install dependencies\nbun run dev                        # Dev server (Node + Vite)\nbun run dev:bun                    # Dev server (Bun native)\nbun run build                      # Production build\nbun run preview                    # Preview production build\nbun run lint                       # ESLint over *.ts/tsx/js/jsx\n\n# Testing — Bun built-in runner (NOT Vitest/Jest)\nbun test                           # All tests\nbun test --watch                   # Watch mode\nbun test --coverage                # With coverage report\nbun test src/api/lightrag.test.ts  # Single test file\n```\n\n### Testing\n\n- Use mock-based tests for external services (Redis, httpx, etc.) — do not depend on live services in unit tests.\n- Add regression tests for every bug fix.\n- Run the full test suite (or relevant subset) and report pass counts before declaring done.\n- Backend tests use pytest; frontend unit tests use Bun's built-in runner — see *WebUI* above.\n\n```bash\n# Preferred for fresh shells and automation; resolves PYTHON, venv, uv, .venv, venv, python, python3\n./scripts/test.sh tests\n\n# Run specific test file\n./scripts/test.sh tests/kg/test_graph_storage.py\n\n# Run with custom workers\n./scripts/test.sh tests --test-workers 4\n```\n\n- `tests/`: main test suite, mirrors feature folders. Place new tests under the subdirectory matching the module under test:\n  - `tests/api/{auth,config,routes}/` for FastAPI server tests (auth/token, config loading, route handlers); top-level `tests/api/` for app-wide concerns (path prefixes, Ollama-compatible endpoint).\n  - `tests/chunker/`, `tests/evaluation/`, `tests/extraction/` for the like-named modules.\n  - `tests/kg/<backend>_impl/` for backend-specific storage tests, mirroring the `lightrag/kg/<backend>_impl.py` file naming. The `_impl` suffix on every subdirectory keeps the layout uniform and avoids `sys.path` shadowing on names that overlap with top-level PyPI/stdlib packages (`faiss`, `json`, `neo4j`, `networkx`, `redis`) when a test is launched directly via `python tests/kg/...`. Current backends: `faiss_impl/`, `json_impl/`, `memgraph_impl/`, `milvus_impl/`, `mongo_impl/`, `nano_impl/`, `neo4j_impl/`, `networkx_impl/`, `opensearch_impl/`, `postgres_impl/`, `qdrant_impl/`, `redis_impl/`. `tests/kg/` root holds cross-backend tests (`test_graph_storage`, `test_batch_graph_operations`, `test_unified_lock_safety`, `test_file_atomic`).\n  - `tests/llm/<provider>_impl/` for provider-specific behavior, same `_impl` convention: `bedrock_impl/`, `gemini_impl/`, `ollama_impl/`, `openai_impl/`, `voyageai_impl/`, `zhipu_impl/`. `tests/llm/` root holds cross-provider concerns (embedding, VLM, cache, role).\n  - `tests/parser/`, `tests/parser/docx/`, `tests/parser/external/{mineru,docling}/` for parser implementations.\n  - `tests/pipeline/` for ingestion pipeline and doc-status behavior (including `test_pipeline_*`, `test_doc_status_*`, `test_multimodal_*`, `test_graph_keyed_locks`).\n  - `tests/sidecar/`, `tests/setup/`, `tests/workspace/` for the like-named cross-cutting concerns.\n  - When adding a new backend or LLM provider, create a new subdirectory plus an empty `__init__.py` rather than dropping the file in the parent directory root.\n- Markers (registered in `[tool.pytest.ini_options]` in `pyproject.toml`): `offline`, `integration`, `requires_db`, `requires_api`, `pg_smoke`. Integration tests are skipped by default via `-m \"not integration\"`; opt in with `--run-integration`.\n- Integration env vars: `LIGHTRAG_RUN_INTEGRATION=true`, `LIGHTRAG_KEEP_ARTIFACTS=true`, `LIGHTRAG_TEST_WORKERS=4`, plus storage-specific connection strings.\n\n### Linting\n```bash\nruff check .\n```\n\n## Key Implementation Patterns\n\n### LightRAG Initialization (Critical)\n\nThe most common error is forgetting to initialize storages (manifests as `AttributeError: __aenter__` or `KeyError: 'history_messages'`):\n\n```python\nimport asyncio\nfrom lightrag import LightRAG\nfrom lightrag.llm.openai import gpt_4o_mini_complete, openai_embed\n\nasync def main():\n    rag = LightRAG(\n        working_dir=\"./rag_storage\",\n        llm_model_func=gpt_4o_mini_complete,\n        embedding_func=openai_embed\n    )\n\n    # REQUIRED: Initialize storage backends\n    await rag.initialize_storages()\n\n    # Now safe to use\n    await rag.ainsert(\"Your text here\")\n    result = await rag.aquery(\"Your question\", param=QueryParam(mode=\"hybrid\"))\n\n    # Cleanup\n    await rag.finalize_storages()\n\nasyncio.run(main())\n```\n\n### Custom Embedding Functions\n\nUse `@wrap_embedding_func_with_attrs` decorator and call `.func` when wrapping (already-decorated functions cannot be wrapped again — access the underlying via `.func`):\n\n```python\nfrom lightrag.utils import wrap_embedding_func_with_attrs\n\n@wrap_embedding_func_with_attrs(embedding_dim=1536, max_token_size=8192)\nasync def custom_embed(texts: list[str]) -> np.ndarray:\n    # Call underlying function, not wrapped version\n    return await openai_embed.func(texts, model=\"text-embedding-3-large\")\n\n# Wrong: EmbeddingFunc(func=openai_embed)\n# Right: EmbeddingFunc(func=openai_embed.func)\n```\n\n> **Pitfall — switching embedding models**: when changing the embedding model you MUST clear the data directory (optionally keeping `kv_store_llm_response_cache.json` for LLM cache). Existing vectors will not match the new model's space.\n\n### Storage Configuration\n\nConfigure via environment variables or constructor params:\n\n```python\n# Environment-based (recommended for production)\n# See env.example for full list\n\n# Constructor-based\nrag = LightRAG(\n    working_dir=\"./storage\",\n    workspace=\"project_name\",  # For data isolation\n    kv_storage=\"PGKVStorage\",\n    vector_storage=\"PGVectorStorage\",\n    graph_storage=\"Neo4JStorage\",\n    doc_status_storage=\"PGDocStatusStorage\",\n    vector_db_storage_cls_kwargs={\n        \"cosine_better_than_threshold\": 0.2\n    }\n)\n```\n\n### Document Insertion\n\n```python\n# Single document\nawait rag.ainsert(\"Text content\")\n\n# Batch insertion\nawait rag.ainsert([\"Text 1\", \"Text 2\", ...])\n\n# With custom IDs\nawait rag.ainsert(\"Text\", ids=[\"doc-123\"])\n\n# With file paths (for citation)\nawait rag.ainsert([\"Text 1\", \"Text 2\"], file_paths=[\"doc1.pdf\", \"doc2.pdf\"])\n\n# Configure batch size\nrag = LightRAG(..., max_parallel_insert=4)  # Default: 3, max recommended: 10\n```\n\n### Query Configuration\n\n```python\nfrom lightrag import QueryParam\n\nresult = await rag.aquery(\n    \"Your question\",\n    param=QueryParam(\n        mode=\"mix\",                    # Recommended with reranker\n        top_k=60,                      # KG entities/relations to retrieve\n        chunk_top_k=20,                # Text chunks to retrieve\n        max_entity_tokens=6000,\n        max_relation_tokens=8000,\n        max_total_tokens=30000,\n        enable_rerank=True,\n        user_prompt=\"Additional instructions for LLM\",\n        stream=False\n    )\n)\n```\n\n## Frontend Debugging via Playwright\n\nFor WebUI bugs whose symptoms only surface in the rendered DOM — layout/overflow/scrollbar issues, transient flashes, third-party libraries attaching helpers to `<body>` outside React's tree, or end-to-end verification of a fix — drive the running dev server (`http://localhost:5173`) with the `document-skills:webapp-testing` skill instead of reasoning from source alone. Seed state directly via `localStorage` (persist key `settings-storage`, schema in `lightrag_webui/src/stores/settings.ts`) to skip live LLM calls. Use `wait_until=\"domcontentloaded\"` plus a selector wait — Vite dev's long-lived polling makes `networkidle` time out.\n\n## Configuration\n\n### .env Configuration\nPrimary configuration file for API server. Generate it with `make env-base` or copy `env.example` manually. Key sections:\n- Server settings (HOST, PORT, CORS)\n- Storage backends (connection strings via environment variables)\n- Query parameters (TOP_K, MAX_TOTAL_TOKENS, etc.)\n- Reranking configuration (RERANK_BINDING, RERANK_MODEL)\n- Authentication (AUTH_ACCOUNTS, LIGHTRAG_API_KEY)\n\nSee `env.example` for comprehensive template.\n\n### Setup Wizard Outputs\n- Keep `.env` host-usable. Container-only hostnames and staged SSL paths belong in the wizard-managed compose layer, not persisted back into `.env`.\n- Treat `docker-compose.final.yml` as generated output assembled from `scripts/setup/templates/*.yml`.\n- For setup workflow changes, prefer `make env-*` targets over direct `scripts/setup/setup.sh` calls.\n\n## Code Style\n\n### Language\nComments, backend code, log messages, and Git commit messages in English. Frontend uses i18next for multi-language support.\n\n### Python\n- Follow PEP 8 with 4-space indentation\n- Use type annotations\n- Prefer dataclasses for state management\n- Use `lightrag.utils.logger` instead of print\n- Async/await patterns throughout\n\n### TypeScript / React (incl. WebUI ESLint)\n- Functional components with hooks; PascalCase for components\n- 2-space indentation, single quotes (enforced by `@stylistic` rules)\n- Tailwind utility-first styling\n- ESLint stack: TypeScript-ESLint + React Hooks plugin + Prettier; `@typescript-eslint/no-explicit-any` is disabled (allowed)\n\n## Commit and Pull Request Guidance\n\n- If this repo is a fork of `HKUDS/LightRAG`. Target to `HKUDS/LightRAG` when creating PRs, not the fork's own repo.\n- PR descriptions should include: summary, motivation, linked issues if applyed, what's changed, what's broken and how it works.\n- Write commit messages (subject and body) in English. Commit messages are repository artifacts — like code comments and log messages — not conversational replies, so they follow the English code-style rule above regardless of any per-conversation working language.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","title":"AGENTS.md","content":"# Repository Guidelines\n\n## Project Overview\n\nLightRAG is a Retrieval-Augmented Generation (RAG) framework that uses graph-based knowledge representation for enhanced information retrieval. The system extracts entities and relationships from documents, builds a knowledge graph, and uses multiple retrieval modes (`local`, `global`, `hybrid`, `mix`, `naive`) for queries.\n\n## Project Structure\n\nTop-level directories:\n\n- **lightrag/**: Core Python package — see *Module Layout* below.\n- **lightrag_webui/**: React 19 + TypeScript client (Bun + Vite + Tailwind). UI components in `src/`.\n- **scripts/**: `test.sh` (preferred test runner), `setup/` interactive environment wizard (use `make env-*` rather than calling `setup.sh` directly — see *Configuration > Setup Wizard Outputs*), and release tooling.\n- **tests/**: Pytest coverage, organized into subdirectories that mirror `lightrag/` (see *Testing* below for layout). Working datasets stay in `inputs/`, `rag_storage/`, and `temp/`; deployment collateral lives in `docs/`, `k8s-deploy/`, and compose files.\n\n### Module Layout (`lightrag/`)\n\n- **lightrag.py**: Main orchestrator class (`LightRAG`) — assembled from mixins (see *LightRAG class composition*). Hosts `ainsert_custom_kg`, `_insert_done`, `_process_extract_entities`, `_refresh_addon_params_cache`, and `addon_params` accessors. Critical: always call `await rag.initialize_storages()` after instantiation.\n- **pipeline.py**: `_PipelineMixin` — owns the document ingestion pipeline (`apipeline_enqueue_documents`, `apipeline_process_enqueue_documents`, `apipeline_process_error_documents`), the `parse_native` / `parse_mineru` / `parse_docling` parser dispatchers, multimodal analysis, validation, and the worker scaffolding.\n- **utils_pipeline.py**: Pure helpers shared by the pipeline mixin and other entry points: doc-status field access, document identity (source key, content hash), parsed-artifact path resolution, parser payload normalization, multimodal entity augmentation, and `make_lightrag_doc_content`.\n- **llm_roles.py**: `RoleSpec` / `RoleLLMConfig` / `_RoleLLMState` / `ROLES` registry plus `_RoleLLMMixin` — role normalization, builder registration, wrapper rebuild, runtime config update, queue cleanup, sanitized config export, queue status reporting. Route role-specific behavior here rather than into provider modules.\n- **storage_migrations.py**: `_StorageMigrationMixin` — `check_and_migrate_data`, `_migrate_entity_relation_data`, `_migrate_chunk_tracking_storage`.\n- **addon_params.py**: `ObservableAddonParams` plus `default_addon_params` / `normalize_addon_params` helpers.\n- **operate.py**: Core extraction and query operations including entity/relation extraction, chunking, and multi-mode retrieval logic.\n- **base.py**: Abstract base classes for storage backends (`BaseKVStorage`, `BaseVectorStorage`, `BaseGraphStorage`, `BaseDocStatusStorage`).\n- **kg/**: Storage implementations (JSON, NetworkX, Neo4j, PostgreSQL, MongoDB, Redis, Milvus, Qdrant, Faiss, Memgraph, OpenSearch, NanoVectorDB). The backend registry (`STORAGE_IMPLEMENTATIONS` / `STORAGES`) lives in `kg/__init__.py`; `kg/factory.py::get_storage_class()` resolves backend classes from configuration.\n- **llm/**: LLM and embedding provider bindings (OpenAI, Ollama, Azure, Gemini, Bedrock, Anthropic, etc.). All async with caching support.\n- **parser/**: Unified parsing layer. `parser/routing.py` resolves engine and filename hints for `legacy`, `native`, `mineru`, and `docling` flows; `parser/debug.py` provides an offline LightRAG stub for the `parser/cli.py` debug entry point (`python -m lightrag.parser.cli`). Native format parsers live as sibling sub-packages under `parser/` (currently `parser/docx/`); external HTTP-based adapters live under `parser/external/` (`mineru`, `docling`) with shared helpers in `parser/external/_common.py`, `_manifest.py`, `_zip.py`.\n- **chunker/**: Chunking strategies (token-size, recursive character, semantic vector, paragraph semantic).\n- **api/**: FastAPI service (`lightrag_server.py`) with REST endpoints and Ollama-compatible API; routers under `routers/`, static Swagger assets, packaged WebUI output, and Gunicorn launcher.\n\n## Core Architecture\n\n### LightRAG class composition\n\n`LightRAG` is assembled from focused mixins (split out of the previously monolithic `lightrag.py`):\n\n```\nLightRAG → _RoleLLMMixin → _StorageMigrationMixin → _PipelineMixin → object\n```\n\nThe `@final` decorator on `LightRAG` is preserved — the mixin layering is an internal implementation detail, not an external subclassing surface. The public API (`ainsert`, `aquery`, `ainsert_custom_kg`, `initialize_storages`, etc.) is unchanged. `ainsert_custom_kg` and its internal construction logic, `_insert_done`, `_process_extract_entities`, `_refresh_addon_params_cache`, and the `addon_params` property accessors stay on `LightRAG` itself because they cut across multiple flows or depend on prompt-profile state.\n\n### Storage Layer\n\nLightRAG uses 4 storage types with pluggable backends:\n- **KV_STORAGE**: LLM response cache, text chunks, document info\n- **VECTOR_STORAGE**: Entity/relation/chunk embeddings\n- **GRAPH_STORAGE**: Entity-relation graph structure\n- **DOC_STATUS_STORAGE**: Document processing status tracking\n\nEach `LightRAG` instance can pass a `workspace` parameter for data isolation. Implementation differs per storage type:\n- **File-based**: subdirectories under `working_dir`.\n- **Collection-based**: collection name prefixes.\n- **Relational DB**: workspace column filtering.\n- **Qdrant**: payload-based partitioning.\n\n### Pipeline concurrency contract\n\nThe document ingestion pipeline coordinates concurrent writers through `pipeline_status` (a per-workspace shared dict in `lightrag.kg.shared_storage`). These fields are mutated under `get_namespace_lock(\"pipeline_status\", workspace=...)`:\n\n- **`busy`**: any pipeline-busy state. Set by both the processing loop AND destructive jobs (clear / per-doc delete). On its own, `busy=True` does NOT block enqueue — see `destructive_busy` for the exclusive subset.\n- **`destructive_busy`**: the busy job is `/documents/clear` or `/documents/{doc_id}` (delete). These DROP storages and remove input files; a concurrent enqueue accepted in this window would write to storage being torn down and silently lose the document. Reservation and the enqueue last-line guard reject when this is True.\n- **`scanning`**: a `/documents/scan` task is running (whole lifecycle: classification + processing). Used by the `/scan` endpoint to refuse overlapping scans. Does NOT on its own block uploads/inserts.\n- **`scanning_exclusive`**: True only during the scan task's classification phase, when `run_scanning_process` is reading `doc_status` to classify files (PROCESSED → archive, FAILED-without-`full_docs` → retry-as-new, etc.) and possibly deleting stale stubs. Reservation and the enqueue last-line guard reject when this is set. Cleared before the scan transitions to its processing phase, allowing concurrent uploads to land while scan-driven processing finishes.\n- **`pending_enqueues`**: count of `/upload`, `/text`, `/texts` endpoints that have reserved a slot (via `_reserve_enqueue_slot`) but whose bg task has not yet completed. Only the scan endpoint reads this — to refuse starting while uploads are mid-flight.\n\n**Workspace pipeline ingress** (`lightrag/kg/pipeline_ingress.py`, resolved via `get_pipeline_ingress(workspace)`): a three-channel mailbox living beside `pipeline_status` (never inside it — the status dict is serialized into API responses). It is the pipeline's only wake-up channel; `doc_status` stays the source of truth (a dropped notification is recovered by the next run's initial strict scan). Enqueue publishes document messages under `pipeline_status_lock` (one `put_documents` batch RPC); a busy-refused `apipeline_process_enqueue_documents` arms the **auto-rescan** flag inside `acquire_processing_reservation`'s own critical section. At every quiescence point the loop decides, atomically under `pipeline_status_lock`, cancellation first (consumes nothing), then: earliest sticky **manual retry** request (peeked, one per cycle) > **auto-rescan** dirty flag (consumed atomically; the loop is the sole consumer and re-arms it if the follow-up strict query fails) > **document** channel non-empty (peeked via `counts()`; resolved by a bounded drain-then-strict-scan refetch that compacts provably-stale messages) > release `busy` (same critical section).\n\n**FAILED retry semantics**: automatic runs resume only `_AUTO_RESUME_DOC_STATUSES` (PENDING + PROCESSING/PARSING/ANALYZING dead-process orphans). A FAILED document re-enters the pipeline exclusively through a sticky manual retry request published by `/documents/scan` (after its reservation is granted) or `/documents/reprocess_failed` (publish-first; pure storage-driven, no filesystem scan, no custom-chunk rollback). Each request grants at most ONE retry attempt (`_MANUAL_RETRY_DOC_STATUSES`, initial scan only) and is ACKed only after the FAILED→PENDING resets persist — a crash re-executes the request or leaves the docs PENDING for automatic recovery; a doc failing again stays FAILED until the next explicit request. All scheduling-control-plane `doc_status` queries use `get_docs_by_statuses(..., strict=True)` (complete-or-raise), and scheduler `full_docs` reads distinguish confirmed-absent (`None`) from backend errors (raise). Manual-intent endpoints start their work through `start_committed_background_task` (fence recheck + publish in one critical section; a post-commit cancellation never cancels the child).\n\nMutual-exclusion rules (all checked atomically inside the lock):\n\n| Operation | Refuses if | Writes |\n|---|---|---|\n| `_reserve_enqueue_slot` | `scanning_exclusive` or `destructive_busy` | `pending_enqueues++` |\n| `apipeline_enqueue_documents` (last-line guard) | (`scanning_exclusive` and not `from_scan`) or `destructive_busy` | — |\n| Scan endpoint reservation | `busy or scanning or pending_enqueues > 0` | `scanning = True` |\n| `apipeline_process_enqueue_documents` entry | (already busy → arm ingress auto-rescan, return) | `busy = True` (NOT `destructive_busy`) |\n| `clear_documents` / `delete_document` (synchronous reservation) | `busy or scanning or pending_enqueues > 0` | `busy = True`, `destructive_busy = True` |\n\nThe contract permits **concurrent enqueue + processing**: a freshly-uploaded doc lands in `doc_status` while the loop is mid-batch, its document message is routed into the running batch by the in-batch feeder (or resolved at the batch boundary by the quiescence decision), and the doc processes without waiting for a new run.\n\nFor the rest — write ordering of `full_docs` vs `doc_status`, the workspace-scoped `enqueue_serialize` lock around dedup-and-upsert, and the `from_scan=True` bypass — see the docstrings on `apipeline_enqueue_documents` and `apipeline_process_enqueue_documents` in `lightrag/pipeline.py`.\n\n### Purge recovery contract\n\nThe KG is shared across documents, so \"what did this document contribute?\" can only be answered from the per-document **write-ahead recovery anchors** (`full_entities` / `full_relations`, written and flushed in `merge_nodes_and_edges` Phase 0 *before* the first graph mutation). The reverse lookup — graph `source_id` → `text_chunks` → `full_doc_id` — is not a fallback, because purge deletes those chunks.\n\nThe governing invariant is narrower than \"every purge needs a proof\":\n\n> **A purge must never delete something that CARRIES attribution — a chunk row or an anchor row that names objects — and leave those objects behind.** An operation that removes no such carrier cannot strand anything and needs no proof.\n\n`_purge_kg_contributions` therefore **fails closed** (`RecoveryAnchorMissingError`, surfaced as HTTP 409, nothing deleted) when it would remove a carrier without one of these proofs. Treating absent anchors as an empty candidate list was issue #3400's silent-skip defect: graph cleanup was skipped while the chunks went anyway, stranding unattributable entities that `audit_kg_integrity` can only report as unrecoverable orphans.\n\n| Proof | Established by |\n|---|---|\n| `anchors` | Both anchor ROWS present and structurally usable. **Row presence is the test, never list truthiness** — an empty row is a document that extracted no entities, and conflating the two is the original bug. |\n| `pre_graph` | `doc_status.metadata.kg_write_state`. Stamped `pre_graph` at enqueue so every pre-merge failure state inherits it by carry-over; advanced to `graph_mutation_started` only by `merge_nodes_and_edges`' `on_anchors_durable` hook. **Monotonic** — nothing writes it back, because re-stamping `pre_graph` on reprocess would let the resume purge skip and orphan the previous run's contributions. Absent means UNKNOWN (pre-#3416), which fails closed. |\n| `journal` | `doc_status.metadata.kg_purge` at a phase past `prepared`, i.e. a previous attempt got far enough to have deleted the anchors itself. |\n| `empty_scope` | No chunks AND no anchor row that names anything — so the delete removes no carrier at all and the invariant is satisfied outright. This is what lets a row enqueued before the marker existed, still holding no chunks, be deleted directly (no scan, no audit). |\n\n**`kg_write_state` must never be inferred.** `pre_graph` asserts \"this document never touched the graph\", which licenses deleting its chunks while *skipping the graph* — sound only because the marker is written once, at enqueue, when it is necessarily true and the document has no history to misread. A backfill keying off a momentarily-empty `chunks_list` would stamp a document that does own graph objects, and because the stamp is durable the damage lands later, when the chunks reappear: chunks deleted, graph skipped, issue #3400 reproduced exactly. `empty_scope` is safe where such a backfill is not, because it is re-evaluated against live state on every call and grants nothing beyond that call. `tests/pipeline/test_purge_fail_closed.py::test_a_false_pre_graph_marker_would_reproduce_the_original_defect` pins the cost.\n\nAnchor-driven whole-document purge is **journaled and resumable** through four ordered phases — `prepared` → `derived_committed` → `anchors_pending` → `completed` — keyed by an operation id over the document key plus its chunk SET. The journal is *required by* fail-closed rather than an optimisation: purge's last step deletes the anchors, so without it any later failure would make every retry refuse forever. A resumed purge skips exactly the phases already persisted (so it never re-runs the LLM-cache-backed rebuild); an in-flight journal for a different operation is refused (`KGPurgeOperationConflictError`), while a stale `completed` one is ignored as dead bookkeeping.\n\nBoth metadata keys are in the `_DOC_STATUS_METADATA_CARRY_OVER_KEYS` **and** `_DOC_STATUS_METADATA_DIRECTIVE_KEYS` whitelists in `lightrag/utils_pipeline.py`; dropping either at a transition or a FAILED→PENDING reset turns a resumable purge into a permanent refusal. Retiring one requires `doc_status_transition_metadata(..., drop=...)` — passing it via `extra` would persist the value, and omitting it lets carry-over restore it.\n\nCallers: `adelete_by_doc_id` (delegates wholly to the primitive; the chunk-less branch runs it too), and the pipeline's resume path `_purge_stale_extraction_if_resuming` (which retires the journal and persists `chunks_list=[]` in one targeted write). Explicit-candidate mode — custom-chunk patch rollback — is neither journaled nor proof-checked, because its own operation journal already names the complete candidate superset; the primitive reads that journal to union in candidates no anchor row can name yet.\n\nA document can legitimately own nothing: `skip_kg` (`process_options` `'!'`) skips extraction and the merge, so no anchor rows are ever written. Post-change those documents carry `pre_graph` and delete normally; older ones have neither proof, and anchor repair has nothing to rebuild from.\n\n**Chunk tracking outranks graph `source_id`.** Within a surviving entity or relation, the `entity_chunks` / `relation_chunks` row is the authoritative chunk list; the graph node's `source_id` is only a truncated view of it (`apply_source_ids_limit`) and may legitimately still name chunks a previous purge already pruned — `_purge_kg_contributions` reads tracking first, falls back to `source_id` only when the row is absent, and its `graph_references_deleted_chunks` branch exists to repair exactly that lag. So code that folds a `source_id` delta back into tracking must append genuine additions only: restoring an ID that is in the graph but not in tracking writes stale attribution into the authoritative store, and a later purge would rebuild or retain KG objects from chunks that no longer exist. `compute_incremental_chunk_ids` carries this rule and `tests/utils/test_compute_incremental_chunk_ids.py` pins it. Genuinely missing attribution is repaired by `audit_kg_integrity`, never by the incremental path.\n\nThe offline remedy for a document with no proof is `audit_kg_integrity(..., apply=True)` (`lightrag/tools/kg_integrity_repair.py`): it rebuilds anchors from surviving chunk provenance, and — because it enumerates the **whole** graph, which the hot paths never do — it can additionally certify that a document appearing nowhere in that scan owns nothing, writing it the empty anchor rows that are the normal proof for such a document (`anchorless_docs` in the report). Absence is only ever concluded from the completed scan; a document that does own graph objects is repaired with its real names, never blanked.\n\n### Query Modes\n\n- **local**: Context-dependent retrieval focused on specific entities\n- **global**: Community/summary-based broad knowledge retrieval\n- **hybrid**: Combines local and global\n- **naive**: Direct vector search without graph\n- **mix**: Integrates KG and vector retrieval (recommended with reranker)\n\n## Development Commands\n\n### Setup\n```bash\n# Install with uv\nuv sync\nsource .venv/bin/activate  # Or: .venv\\Scripts\\activate on Windows\n\n# Install with API support\nuv sync --extra api\n\n# Install specific extras\nuv sync --extra offline-storage  # Storage backends\nuv sync --extra offline-llm      # LLM providers\nuv sync --extra test             # Testing dependencies\n```\n\n### API Server\n```bash\n# Copy and configure environment\ncp env.example .env  # Edit with your LLM/embedding configs\n\n# Build WebUI\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n\n# Run server\nlightrag-server                                           # Production\nuvicorn lightrag.api.lightrag_server:app --reload        # Development\nlightrag-gunicorn                                         # Multi-worker (gunicorn)\n```\n\n### WebUI\n```bash\ncd lightrag_webui\nbun install --frozen-lockfile      # Install dependencies\nbun run dev                        # Dev server (Node + Vite)\nbun run dev:bun                    # Dev server (Bun native)\nbun run build                      # Production build\nbun run preview                    # Preview production build\nbun run lint                       # ESLint over *.ts/tsx/js/jsx\n\n# Testing — Bun built-in runner (NOT Vitest/Jest)\nbun test                           # All tests\nbun test --watch                   # Watch mode\nbun test --coverage                # With coverage report\nbun test src/api/lightrag.test.ts  # Single test file\n```\n\n### Testing\n\n- Use mock-based tests for external services (Redis, httpx, etc.) — do not depend on live services in unit tests.\n- Add regression tests for every bug fix.\n- Run the full test suite (or relevant subset) and report pass counts before declaring done.\n- Backend tests use pytest; frontend unit tests use Bun's built-in runner — see *WebUI* above.\n\n```bash\n# Preferred for fresh shells and automation; resolves PYTHON, venv, uv, .venv, venv, python, python3\n./scripts/test.sh tests\n\n# Run specific test file\n./scripts/test.sh tests/kg/test_graph_storage.py\n\n# Run with custom workers\n./scripts/test.sh tests --test-workers 4\n```\n\n- `tests/`: main test suite, mirrors feature folders. Place new tests under the subdirectory matching the module under test:\n  - `tests/api/{auth,config,routes}/` for FastAPI server tests (auth/token, config loading, route handlers); top-level `tests/api/` for app-wide concerns (path prefixes, Ollama-compatible endpoint).\n  - `tests/chunker/`, `tests/evaluation/`, `tests/extraction/` for the like-named modules.\n  - `tests/kg/<backend>_impl/` for backend-specific storage tests, mirroring the `lightrag/kg/<backend>_impl.py` file naming. The `_impl` suffix on every subdirectory keeps the layout uniform and avoids `sys.path` shadowing on names that overlap with top-level PyPI/stdlib packages (`faiss`, `json`, `neo4j`, `networkx`, `redis`) when a test is launched directly via `python tests/kg/...`. Current backends: `faiss_impl/`, `json_impl/`, `memgraph_impl/`, `milvus_impl/`, `mongo_impl/`, `nano_impl/`, `neo4j_impl/`, `networkx_impl/`, `opensearch_impl/`, `postgres_impl/`, `qdrant_impl/`, `redis_impl/`. `tests/kg/` root holds cross-backend tests (`test_graph_storage`, `test_batch_graph_operations`, `test_unified_lock_safety`, `test_file_atomic`).\n  - `tests/llm/<provider>_impl/` for provider-specific behavior, same `_impl` convention: `bedrock_impl/`, `gemini_impl/`, `ollama_impl/`, `openai_impl/`, `voyageai_impl/`, `zhipu_impl/`. `tests/llm/` root holds cross-provider concerns (embedding, VLM, cache, role).\n  - `tests/parser/`, `tests/parser/docx/`, `tests/parser/external/{mineru,docling}/` for parser implementations.\n  - `tests/pipeline/` for ingestion pipeline and doc-status behavior (including `test_pipeline_*`, `test_doc_status_*`, `test_multimodal_*`, `test_graph_keyed_locks`).\n  - `tests/sidecar/`, `tests/setup/`, `tests/workspace/` for the like-named cross-cutting concerns.\n  - When adding a new backend or LLM provider, create a new subdirectory plus an empty `__init__.py` rather than dropping the file in the parent directory root.\n- Markers (registered in `[tool.pytest.ini_options]` in `pyproject.toml`): `offline`, `integration`, `requires_db`, `requires_api`, `pg_smoke`. Integration tests are skipped by default via `-m \"not integration\"`; opt in with `--run-integration`.\n- Integration env vars: `LIGHTRAG_RUN_INTEGRATION=true`, `LIGHTRAG_KEEP_ARTIFACTS=true`, `LIGHTRAG_TEST_WORKERS=4`, plus storage-specific connection strings.\n\n### Linting\n```bash\nruff check .\n```\n\n## Key Implementation Patterns\n\n### LightRAG Initialization (Critical)\n\nThe most common error is forgetting to initialize storages (manifests as `AttributeError: __aenter__` or `KeyError: 'history_messages'`):\n\n```python\nimport asyncio\nfrom lightrag import LightRAG\nfrom lightrag.llm.openai import gpt_4o_mini_complete, openai_embed\n\nasync def main():\n    rag = LightRAG(\n        working_dir=\"./rag_storage\",\n        llm_model_func=gpt_4o_mini_complete,\n        embedding_func=openai_embed\n    )\n\n    # REQUIRED: Initialize storage backends\n    await rag.initialize_storages()\n\n    # Now safe to use\n    await rag.ainsert(\"Your text here\")\n    result = await rag.aquery(\"Your question\", param=QueryParam(mode=\"hybrid\"))\n\n    # Cleanup\n    await rag.finalize_storages()\n\nasyncio.run(main())\n```\n\n### Custom Embedding Functions\n\nUse `@wrap_embedding_func_with_attrs` decorator and call `.func` when wrapping (already-decorated functions cannot be wrapped again — access the underlying via `.func`):\n\n```python\nfrom lightrag.utils import wrap_embedding_func_with_attrs\n\n@wrap_embedding_func_with_attrs(embedding_dim=1536, max_token_size=8192)\nasync def custom_embed(texts: list[str]) -> np.ndarray:\n    # Call underlying function, not wrapped version\n    return await openai_embed.func(texts, model=\"text-embedding-3-large\")\n\n# Wrong: EmbeddingFunc(func=openai_embed)\n# Right: EmbeddingFunc(func=openai_embed.func)\n```\n\n> **Pitfall — switching embedding models**: when changing the embedding model you MUST clear the data directory (optionally keeping `kv_store_llm_response_cache.json` for LLM cache). Existing vectors will not match the new model's space.\n\n### Storage Configuration\n\nConfigure via environment variables or constructor params:\n\n```python\n# Environment-based (recommended for production)\n# See env.example for full list\n\n# Constructor-based\nrag = LightRAG(\n    working_dir=\"./storage\",\n    workspace=\"project_name\",  # For data isolation\n    kv_storage=\"PGKVStorage\",\n    vector_storage=\"PGVectorStorage\",\n    graph_storage=\"Neo4JStorage\",\n    doc_status_storage=\"PGDocStatusStorage\",\n    vector_db_storage_cls_kwargs={\n        \"cosine_better_than_threshold\": 0.2\n    }\n)\n```\n\n### Document Insertion\n\n```python\n# Single document\nawait rag.ainsert(\"Text content\")\n\n# Batch insertion\nawait rag.ainsert([\"Text 1\", \"Text 2\", ...])\n\n# With custom IDs\nawait rag.ainsert(\"Text\", ids=[\"doc-123\"])\n\n# With file paths (for citation)\nawait rag.ainsert([\"Text 1\", \"Text 2\"], file_paths=[\"doc1.pdf\", \"doc2.pdf\"])\n\n# Configure batch size\nrag = LightRAG(..., max_parallel_insert=4)  # Default: 3, max recommended: 10\n```\n\n### Query Configuration\n\n```python\nfrom lightrag import QueryParam\n\nresult = await rag.aquery(\n    \"Your question\",\n    param=QueryParam(\n        mode=\"mix\",                    # Recommended with reranker\n        top_k=60,                      # KG entities/relations to retrieve\n        chunk_top_k=20,                # Text chunks to retrieve\n        max_entity_tokens=6000,\n        max_relation_tokens=8000,\n        max_total_tokens=30000,\n        enable_rerank=True,\n        user_prompt=\"Additional instructions for LLM\",\n        stream=False\n    )\n)\n```\n\n## Frontend Debugging via Playwright\n\nFor WebUI bugs whose symptoms only surface in the rendered DOM — layout/overflow/scrollbar issues, transient flashes, third-party libraries attaching helpers to `<body>` outside React's tree, or end-to-end verification of a fix — drive the running dev server (`http://localhost:5173`) with the `document-skills:webapp-testing` skill instead of reasoning from source alone. Seed state directly via `localStorage` (persist key `settings-storage`, schema in `lightrag_webui/src/stores/settings.ts`) to skip live LLM calls. Use `wait_until=\"domcontentloaded\"` plus a selector wait — Vite dev's long-lived polling makes `networkidle` time out.\n\n## Configuration\n\n### .env Configuration\nPrimary configuration file for API server. Generate it with `make env-base` or copy `env.example` manually. Key sections:\n- Server settings (HOST, PORT, CORS)\n- Storage backends (connection strings via environment variables)\n- Query parameters (TOP_K, MAX_TOTAL_TOKENS, etc.)\n- Reranking configuration (RERANK_BINDING, RERANK_MODEL)\n- Authentication (AUTH_ACCOUNTS, LIGHTRAG_API_KEY)\n\nSee `env.example` for comprehensive template.\n\n### Setup Wizard Outputs\n- Keep `.env` host-usable. Container-only hostnames and staged SSL paths belong in the wizard-managed compose layer, not persisted back into `.env`.\n- Treat `docker-compose.final.yml` as generated output assembled from `scripts/setup/templates/*.yml`.\n- For setup workflow changes, prefer `make env-*` targets over direct `scripts/setup/setup.sh` calls.\n\n## Code Style\n\n### Language\nComments, backend code, log messages, and Git commit messages in English. Frontend uses i18next for multi-language support.\n\n### Python\n- Follow PEP 8 with 4-space indentation\n- Use type annotations\n- Prefer dataclasses for state management\n- Use `lightrag.utils.logger` instead of print\n- Async/await patterns throughout\n\n### TypeScript / React (incl. WebUI ESLint)\n- Functional components with hooks; PascalCase for components\n- 2-space indentation, single quotes (enforced by `@stylistic` rules)\n- Tailwind utility-first styling\n- ESLint stack: TypeScript-ESLint + React Hooks plugin + Prettier; `@typescript-eslint/no-explicit-any` is disabled (allowed)\n\n## Commit and Pull Request Guidance\n\n- If this repo is a fork of `HKUDS/LightRAG`. Target to `HKUDS/LightRAG` when creating PRs, not the fork's own repo.\n- PR descriptions should include: summary, motivation, linked issues if applyed, what's changed, what's broken and how it works.\n- Write commit messages (subject and body) in English. Commit messages are repository artifacts — like code comments and log messages — not conversational replies, so they follow the English code-style rule above regardless of any per-conversation working language.\n","category":"root","tokens":7110}]}