* Remove outdated documentation files: Quick Start Guide, Apache AGE Analysis, and Scratchpad. * Add multi-tenant testing strategy and ADR index documentation - Introduced ADR 008 detailing the multi-tenant testing strategy for the ./starter environment, covering compatibility and multi-tenant modes, testing scenarios, and implementation details. - Created a comprehensive ADR index (README.md) summarizing all architecture decision records related to the multi-tenant implementation, including purpose, key sections, and reading paths for different roles. * feat(docs): Add comprehensive multi-tenancy guide and README for LightRAG Enterprise - Introduced `0008-multi-tenancy.md` detailing multi-tenancy architecture, key concepts, roles, permissions, configuration, and API endpoints. - Created `README.md` as the main documentation index, outlining features, quick start, system overview, and deployment options. - Documented the LightRAG architecture, storage backends, LLM integrations, and query modes. - Established a task log (`2025-01-21-lightrag-documentation-log.md`) summarizing documentation creation actions, decisions, and insights.
21 KiB
21 KiB
ADR 004: API Design and Routing
Status: Proposed
Overview
This document specifies the API design for the multi-tenant, multi-knowledge-base architecture, including endpoint structure, request/response models, authentication, and error handling.
API Versioning and Structure
Base URL
https://lightrag.example.com/api/v1
URL Path Structure
/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/{resource_type}/{operation}
Example Endpoints
POST /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/add
GET /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/{doc_id}
POST /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query
DELETE /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/{doc_id}
GET /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/graph
POST /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/entities/{entity_id}/delete
Authentication Mechanisms
1. JWT Bearer Token Authentication
Token Creation
class TokenPayload(BaseModel):
sub: str # User ID
tenant_id: str # Assigned tenant
knowledge_base_ids: List[str] # Accessible KBs (or ["*"] for all)
role: str # admin | editor | viewer
permissions: Dict[str, bool] # Specific permissions
exp: int # Expiration time (Unix timestamp)
iat: int # Issued at time
jti: str # JWT ID (for revocation)
Usage
# Request with JWT token
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/query \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
-H "Content-Type: application/json" \
-d '{"query": "What is the product roadmap?"}'
Token Validation
async def validate_token(token: str) -> TokenPayload:
"""Validate JWT token and return payload"""
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm]
)
# Verify expiration
exp_time = datetime.fromtimestamp(payload["exp"])
if datetime.utcnow() > exp_time:
raise HTTPException(status_code=401, detail="Token expired")
return TokenPayload(**payload)
except jwt.DecodeError:
raise HTTPException(status_code=401, detail="Invalid token")
2. API Key Authentication
API Key Format
X-API-Key: sk-tenant_12345_kb_67890_randomstring1234567890
API Key Structure
sk-{tenant_id}_{kb_id}_{random_bytes}
Usage
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/query \
-H "X-API-Key: sk-acme_docs_xyz123..." \
-H "Content-Type: application/json" \
-d '{"query": "What is the product roadmap?"}'
API Key Management Endpoints
@router.post("/api/v1/tenants/{tenant_id}/api-keys")
async def create_api_key(
request: CreateAPIKeyRequest,
tenant_context: TenantContext = Depends(get_tenant_context),
) -> APIKeyResponse:
"""Create a new API key for a tenant"""
# Generate hashed key
api_key = APIKeyService.generate_api_key(
tenant_id=tenant_context.tenant_id,
kb_id=request.kb_id,
permissions=request.permissions
)
# Store hashed version
await api_key_service.store_api_key(api_key)
# Return key (only once, must be saved by client)
return APIKeyResponse(
key_id=api_key.key_id,
key=api_key.unhashed_key, # Only returned once
created_at=api_key.created_at
)
@router.get("/api/v1/tenants/{tenant_id}/api-keys")
async def list_api_keys(
tenant_context: TenantContext = Depends(get_tenant_context),
) -> List[APIKeyMetadata]:
"""List API keys (without revealing the key itself)"""
keys = await api_key_service.list_keys(tenant_context.tenant_id)
return [
APIKeyMetadata(
key_id=k.key_id,
key_name=k.key_name,
created_at=k.created_at,
last_used_at=k.last_used_at,
permissions=k.permissions
)
for k in keys
]
@router.delete("/api/v1/tenants/{tenant_id}/api-keys/{key_id}")
async def revoke_api_key(
key_id: str,
tenant_context: TenantContext = Depends(get_tenant_context),
) -> dict:
"""Revoke an API key"""
await api_key_service.revoke_key(key_id)
return {"status": "success", "message": "API key revoked"}
Tenant Management Endpoints
Create Tenant
@router.post("/api/v1/tenants")
async def create_tenant(
request: CreateTenantRequest,
admin_token: str = Depends(validate_admin_token),
) -> TenantResponse:
"""Create a new tenant (admin only)"""
tenant = await tenant_service.create_tenant(
tenant_name=request.tenant_name,
description=request.description,
config=request.config or TenantConfig()
)
return TenantResponse(
tenant_id=tenant.tenant_id,
tenant_name=tenant.tenant_name,
description=tenant.description,
created_at=tenant.created_at,
is_active=tenant.is_active
)
# Request model
class CreateTenantRequest(BaseModel):
tenant_name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
config: Optional[TenantConfigRequest] = None
class TenantConfigRequest(BaseModel):
llm_model: Optional[str] = "gpt-4o-mini"
embedding_model: Optional[str] = "bge-m3:latest"
chunk_size: Optional[int] = 1200
top_k: Optional[int] = 40
Get Tenant
@router.get("/api/v1/tenants/{tenant_id}")
async def get_tenant(
tenant_context: TenantContext = Depends(get_tenant_context),
) -> TenantResponse:
"""Get tenant details"""
tenant = await tenant_service.get_tenant(tenant_context.tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return TenantResponse.from_tenant(tenant)
Update Tenant
@router.put("/api/v1/tenants/{tenant_id}")
async def update_tenant(
request: UpdateTenantRequest,
tenant_context: TenantContext = Depends(get_tenant_context),
) -> TenantResponse:
"""Update tenant configuration"""
if not has_permission(tenant_context, "tenant:manage"):
raise HTTPException(status_code=403, detail="Access denied")
tenant = await tenant_service.update_tenant(
tenant_id=tenant_context.tenant_id,
**request.dict(exclude_none=True)
)
return TenantResponse.from_tenant(tenant)
Knowledge Base Endpoints
Create Knowledge Base
@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases")
async def create_knowledge_base(
request: CreateKBRequest,
tenant_context: TenantContext = Depends(get_tenant_context),
) -> KBResponse:
"""Create a knowledge base in a tenant"""
if not has_permission(tenant_context, "kb:create"):
raise HTTPException(status_code=403, detail="Access denied")
kb = await tenant_service.create_knowledge_base(
tenant_id=tenant_context.tenant_id,
kb_name=request.kb_name,
description=request.description
)
return KBResponse.from_kb(kb)
class CreateKBRequest(BaseModel):
kb_name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
List Knowledge Bases
@router.get("/api/v1/tenants/{tenant_id}/knowledge-bases")
async def list_knowledge_bases(
tenant_context: TenantContext = Depends(get_tenant_context),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
) -> PaginatedKBResponse:
"""List all KBs accessible to the user"""
kbs = await tenant_service.list_knowledge_bases(
tenant_id=tenant_context.tenant_id,
accessible_kb_ids=tenant_context.knowledge_base_ids,
skip=skip,
limit=limit
)
return PaginatedKBResponse(
items=[KBResponse.from_kb(kb) for kb in kbs],
total=kbs.total,
skip=skip,
limit=limit
)
Delete Knowledge Base
@router.delete("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}")
async def delete_knowledge_base(
kb_id: str,
tenant_context: TenantContext = Depends(get_tenant_context),
) -> dict:
"""Delete a knowledge base"""
if not has_permission(tenant_context, "kb:delete"):
raise HTTPException(status_code=403, detail="Access denied")
await tenant_service.delete_knowledge_base(
tenant_id=tenant_context.tenant_id,
kb_id=kb_id
)
return {"status": "success", "message": "Knowledge base deleted"}
Document Endpoints
Add Document
@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/add")
async def add_document(
tenant_id: str = Path(...),
kb_id: str = Path(...),
file: UploadFile = File(...),
metadata: Optional[str] = Form(None), # JSON string
tenant_context: TenantContext = Depends(get_tenant_context),
rag_manager = Depends(get_rag_manager),
) -> DocumentAddResponse:
"""
Add a document to a knowledge base.
Returns a track_id for monitoring progress via websocket or polling.
"""
if not has_permission(tenant_context, "document:create"):
raise HTTPException(status_code=403, detail="Access denied")
# Validate file
if not is_allowed_file(file.filename):
raise HTTPException(status_code=400, detail="File type not allowed")
# Get tenant-specific RAG instance
rag = await rag_manager.get_rag_instance(tenant_id, kb_id)
# Start document processing (async)
track_id = generate_track_id()
asyncio.create_task(
process_document(
rag=rag,
file=file,
metadata=metadata,
track_id=track_id,
tenant_context=tenant_context
)
)
return DocumentAddResponse(
status="processing",
track_id=track_id,
message="Document is being processed"
)
class DocumentAddResponse(BaseModel):
status: str # processing | success | error
track_id: str
message: Optional[str] = None
doc_id: Optional[str] = None
Get Document Status
@router.get("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/{doc_id}/status")
async def get_document_status(
doc_id: str,
tenant_context: TenantContext = Depends(get_tenant_context),
) -> DocumentStatusResponse:
"""Get document processing status"""
status = await doc_status_service.get_status(
doc_id=doc_id,
tenant_id=tenant_context.tenant_id,
kb_id=tenant_context.kb_id
)
return DocumentStatusResponse(
doc_id=doc_id,
status=status.status, # ready | processing | error
chunks_processed=status.chunks_processed,
entities_extracted=status.entities_extracted,
relationships_extracted=status.relationships_extracted,
error_message=status.error_message
)
Delete Document
@router.delete("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/{doc_id}")
async def delete_document(
doc_id: str,
tenant_context: TenantContext = Depends(get_tenant_context),
rag_manager = Depends(get_rag_manager),
) -> dict:
"""Delete a document from knowledge base"""
if not has_permission(tenant_context, "document:delete"):
raise HTTPException(status_code=403, detail="Access denied")
# Verify document belongs to this tenant/KB
doc = await doc_service.get_document(doc_id, tenant_context.tenant_id, tenant_context.kb_id)
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
# Delete from RAG
rag = await rag_manager.get_rag_instance(
tenant_context.tenant_id,
tenant_context.kb_id
)
await rag.adelete_by_doc_id(doc_id)
return {"status": "success", "message": "Document deleted"}
Query Endpoints
Standard Query
@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query")
async def query_knowledge_base(
request: QueryRequest,
tenant_context: TenantContext = Depends(get_tenant_context),
rag_manager = Depends(get_rag_manager),
) -> QueryResponse:
"""
Execute a query against a knowledge base.
Returns the generated response with optional references.
"""
if not has_permission(tenant_context, "query:run"):
raise HTTPException(status_code=403, detail="Access denied")
# Validate query
if len(request.query) < 3:
raise HTTPException(status_code=400, detail="Query too short")
# Get tenant-specific RAG instance
rag = await rag_manager.get_rag_instance(
tenant_context.tenant_id,
tenant_context.kb_id
)
# Execute query with tenant context
result = await rag.aquery(
query=request.query,
param=QueryParam(
mode=request.mode or "mix",
top_k=request.top_k or 40,
stream=False
)
)
return QueryResponse(
response=result.response,
references=result.references if request.include_references else None,
metadata={
"mode": request.mode,
"top_k": request.top_k,
"processing_time_ms": result.processing_time
}
)
class QueryRequest(BaseModel):
query: str = Field(..., min_length=3, max_length=2000)
mode: Optional[str] = Field("mix", regex="local|global|hybrid|naive|mix|bypass")
top_k: Optional[int] = Field(None, ge=1, le=100)
include_references: bool = Field(True)
stream: bool = Field(False)
class QueryResponse(BaseModel):
response: str
references: Optional[List[Dict[str, str]]] = None
metadata: Dict[str, Any] = {}
Streaming Query
@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query/stream")
async def query_knowledge_base_stream(
request: QueryRequest,
tenant_context: TenantContext = Depends(get_tenant_context),
rag_manager = Depends(get_rag_manager),
) -> StreamingResponse:
"""
Execute a query with streaming response.
Returns Server-Sent Events (SSE) with streamed tokens and metadata.
"""
if not has_permission(tenant_context, "query:run"):
raise HTTPException(status_code=403, detail="Access denied")
async def stream_response():
# Get RAG instance
rag = await rag_manager.get_rag_instance(
tenant_context.tenant_id,
tenant_context.kb_id
)
# Stream the response
async for chunk in rag.aquery_stream(
query=request.query,
param=QueryParam(
mode=request.mode or "mix",
top_k=request.top_k or 40,
stream=True
)
):
# Emit Server-Sent Event
yield f"data: {json.dumps(chunk)}\n\n"
return StreamingResponse(
stream_response(),
media_type="text/event-stream"
)
Query with Data
@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query/data")
async def query_knowledge_base_data(
request: QueryRequest,
tenant_context: TenantContext = Depends(get_tenant_context),
rag_manager = Depends(get_rag_manager),
) -> QueryDataResponse:
"""
Execute a query and return full context data.
Returns entities, relationships, chunks, and references.
"""
if not has_permission(tenant_context, "query:run"):
raise HTTPException(status_code=403, detail="Access denied")
rag = await rag_manager.get_rag_instance(
tenant_context.tenant_id,
tenant_context.kb_id
)
result = await rag.aquery_with_data(
query=request.query,
param=QueryParam(mode=request.mode or "mix", top_k=request.top_k or 40)
)
return QueryDataResponse(
status="success",
message="Query executed successfully",
data={
"entities": result.entities,
"relationships": result.relationships,
"chunks": result.chunks,
"response": result.response
},
metadata={
"mode": request.mode,
"entity_count": len(result.entities),
"relationship_count": len(result.relationships),
"chunk_count": len(result.chunks)
}
)
class QueryDataResponse(BaseModel):
status: str
message: str
data: Dict[str, Any]
metadata: Dict[str, Any]
Graph Endpoints
Get Graph
@router.get("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/graph")
async def get_graph(
tenant_context: TenantContext = Depends(get_tenant_context),
rag_manager = Depends(get_rag_manager),
max_nodes: int = Query(100, ge=10, le=1000),
entity_type: Optional[str] = None,
) -> GraphResponse:
"""Get knowledge graph visualization data"""
if not has_permission(tenant_context, "kb:access"):
raise HTTPException(status_code=403, detail="Access denied")
rag = await rag_manager.get_rag_instance(
tenant_context.tenant_id,
tenant_context.kb_id
)
graph_data = await rag.get_graph(
max_nodes=max_nodes,
entity_type=entity_type
)
return GraphResponse(
nodes=graph_data.nodes,
edges=graph_data.edges,
metadata={
"node_count": len(graph_data.nodes),
"edge_count": len(graph_data.edges)
}
)
Error Responses
Standard Error Response
class ErrorResponse(BaseModel):
status: str = "error"
code: str # error code for client handling
message: str
details: Optional[Dict[str, Any]] = None
request_id: str # For tracking
# Example error codes
ERROR_CODES = {
"INVALID_TENANT": "Specified tenant does not exist",
"INVALID_KB": "Specified knowledge base does not exist",
"UNAUTHORIZED": "Authentication failed",
"FORBIDDEN": "User does not have permission",
"INVALID_REQUEST": "Request validation failed",
"INTERNAL_ERROR": "Internal server error",
"RATE_LIMITED": "Too many requests",
"QUOTA_EXCEEDED": "Resource quota exceeded"
}
Example Error Response
{
"status": "error",
"code": "FORBIDDEN",
"message": "You do not have permission to access this knowledge base",
"details": {
"required_permission": "kb:access",
"user_permissions": ["query:run"]
},
"request_id": "req-12345"
}
Request/Response Headers
Request Headers
Authorization: Bearer <jwt_token>
or
X-API-Key: <api_key>
X-Request-ID: <unique_request_id> (optional, generated if not provided)
X-Tenant-ID: <tenant_id> (optional, extracted from path)
X-KB-ID: <kb_id> (optional, extracted from path)
Response Headers
X-Request-ID: <unique_request_id>
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1703123456
Content-Type: application/json
Rate Limiting
Per-Tenant Rate Limits
class RateLimitConfig:
# Per tenant
QUERIES_PER_MINUTE = 100
DOCUMENTS_PER_HOUR = 50
API_CALLS_PER_MONTH = 100000
# Global
GLOBAL_QPS = 10000 # Queries per second
# Implement with Redis
@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query")
async def query_with_rate_limit(
request: QueryRequest,
tenant_context: TenantContext = Depends(get_tenant_context),
rate_limiter = Depends(get_rate_limiter)
):
# Check rate limit
await rate_limiter.check_limit(
key=f"{tenant_context.tenant_id}:queries",
limit=RateLimitConfig.QUERIES_PER_MINUTE,
window=60
)
# Execute query
# ...
API Documentation
OpenAPI/Swagger
app = FastAPI(
title="LightRAG Multi-Tenant API",
description="API for multi-tenant RAG system",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
)
Example cURL Commands
# Create tenant (admin)
curl -X POST https://lightrag.example.com/api/v1/tenants \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"tenant_name": "Acme Corp",
"description": "Our main tenant"
}'
# Create knowledge base
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases \
-H "Authorization: Bearer <tenant_token>" \
-H "Content-Type: application/json" \
-d '{
"kb_name": "Product Docs",
"description": "Product documentation"
}'
# Add document
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/documents/add \
-H "Authorization: Bearer <tenant_token>" \
-F "file=@document.pdf"
# Query knowledge base
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/query \
-H "Authorization: Bearer <tenant_token>" \
-H "Content-Type: application/json" \
-d '{
"query": "What is the product roadmap?",
"mode": "mix",
"top_k": 10,
"include_references": true
}'
# Stream query
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/query/stream \
-H "Authorization: Bearer <tenant_token>" \
-H "Content-Type: application/json" \
-d '{"query": "Product roadmap?"}' \
--stream
Document Version: 1.0
Last Updated: 2025-11-20
Related Files: 001-multi-tenant-architecture-overview.md, 002-implementation-strategy.md