Skip to content

Commit bb2227e

Browse files
authored
Feature: speed up graph index (#968)
* feat: refine query context * feat: refine query context * feat: refine query context * feat: refine query context * feat: speed up graph index * feat: speed up graph index
1 parent b490526 commit bb2227e

8 files changed

Lines changed: 893 additions & 263 deletions

File tree

aperag/graph/lightrag/constants.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,5 @@
4141

4242
# Default values for environment variables
4343
DEFAULT_MAX_TOKEN_SUMMARY = 500
44-
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE = 6
45-
DEFAULT_WOKERS = 2
44+
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE = 10
4645
DEFAULT_TIMEOUT = 150
47-
48-
# Logging configuration defaults
49-
DEFAULT_LOG_MAX_BYTES = 10485760 # Default 10MB
50-
DEFAULT_LOG_BACKUP_COUNT = 5 # Default 5 backups
51-
DEFAULT_LOG_FILENAME = "lightrag.log" # Default log filename

aperag/graph/lightrag/lightrag.py

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
final,
4848
)
4949

50-
from aperag.concurrent_control import MultiLock, get_or_create_lock
5150
from aperag.graph.lightrag.constants import (
5251
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
5352
DEFAULT_MAX_TOKEN_SUMMARY,
@@ -214,7 +213,7 @@ class LightRAG:
214213
llm_model_max_token_size: int = field(default=32768)
215214
"""Maximum number of tokens allowed per LLM response."""
216215

217-
llm_model_max_async: int = field(default=4)
216+
llm_model_max_async: int = field(default=8)
218217
"""Maximum number of concurrent LLM calls."""
219218

220219
llm_model_kwargs: dict[str, Any] = field(default_factory=dict)
@@ -440,29 +439,28 @@ def _find_connected_components(self, chunk_results: List[tuple[dict, dict]]) ->
440439
logger.info(f"Found {len(components)} connected components from {len(adjacency)} entities")
441440
return components
442441

443-
async def _process_entity_groups(
442+
async def _grouping_process_chunk_results(
444443
self,
445444
chunk_results: List[tuple[dict, dict]],
446-
components: List[List[str]],
447445
collection_id: str | None = None,
448446
) -> dict[str, Any]:
449447
"""
450448
Process entities and relationships in groups based on connected components.
451449
452450
Args:
453451
chunk_results: List of (nodes_dict, edges_dict) from entity extraction
454-
components: List of entity groups (connected components)
455452
collection_id: Optional collection ID for logging
456453
457454
Returns:
458455
Dict with processing results
459456
"""
457+
components = self._find_connected_components(chunk_results)
458+
460459
workspace = self.workspace if self.workspace else "default"
461460
lightrag_logger = create_lightrag_logger(prefix="LightRAG-GraphIndex", workspace=workspace)
462461

463-
total_entities = 0
464-
total_relations = 0
465-
processed_groups = 0
462+
# Prepare component data for parallel processing
463+
component_tasks = []
466464

467465
for i, component in enumerate(components):
468466
# Create a set for quick lookup
@@ -492,19 +490,31 @@ async def _process_entity_groups(
492490
if not component_chunk_results:
493491
continue
494492

495-
# Create locks for all entities in this component
496-
entity_locks = []
497-
for entity_name in sorted(component): # Sort to prevent deadlock
498-
lock = get_or_create_lock(f"entity:{entity_name}:{self.workspace}")
499-
entity_locks.append(lock)
493+
# Add task data for this component
494+
component_tasks.append(
495+
{
496+
"index": i,
497+
"component": component,
498+
"component_chunk_results": component_chunk_results,
499+
"total_components": len(components),
500+
}
501+
)
502+
503+
# Process components concurrently with semaphore
504+
semaphore = asyncio.Semaphore(self.llm_model_max_async)
500505

501-
lightrag_logger.info(f"Processing component {i + 1}/{len(components)} with {len(component)} entities")
506+
async def _process_component_with_semaphore(task_data):
507+
async with semaphore:
508+
lightrag_logger.info(
509+
f"Processing component {task_data['index'] + 1}/{task_data['total_components']} "
510+
f"with {len(task_data['component'])} entities"
511+
)
502512

503-
# Use MultiLock to acquire all locks for this component
504-
async with MultiLock(entity_locks):
505-
# Merge nodes and edges for this component
506-
await merge_nodes_and_edges(
507-
chunk_results=component_chunk_results,
513+
# Call merge_nodes_and_edges with component information
514+
result = await merge_nodes_and_edges(
515+
chunk_results=task_data["component_chunk_results"],
516+
component=task_data["component"],
517+
workspace=self.workspace,
508518
knowledge_graph_inst=self.chunk_entity_relation_graph,
509519
entity_vdb=self.entities_vdb,
510520
relationships_vdb=self.relationships_vdb,
@@ -517,17 +527,43 @@ async def _process_entity_groups(
517527
lightrag_logger=lightrag_logger,
518528
)
519529

520-
# Count entities and relations in this component
521-
component_entity_count = sum(len(nodes) for nodes, _ in component_chunk_results)
522-
component_relation_count = sum(len(edges) for _, edges in component_chunk_results)
530+
lightrag_logger.info(
531+
f"Completed component {task_data['index'] + 1}: "
532+
f"{result['entity_count']} entities, {result['relation_count']} relations"
533+
)
523534

524-
total_entities += component_entity_count
525-
total_relations += component_relation_count
526-
processed_groups += 1
535+
return result
527536

528-
lightrag_logger.info(
529-
f"Completed component {i + 1}: {component_entity_count} entities, {component_relation_count} relations"
530-
)
537+
# Create tasks for concurrent processing
538+
tasks = []
539+
for task_data in component_tasks:
540+
task = asyncio.create_task(_process_component_with_semaphore(task_data))
541+
tasks.append(task)
542+
543+
# Wait for all tasks to complete or for the first exception
544+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
545+
546+
# Check if any task raised an exception
547+
for task in done:
548+
if task.exception():
549+
# Cancel all pending tasks
550+
for pending_task in pending:
551+
pending_task.cancel()
552+
553+
# Wait for cancellation to complete
554+
if pending:
555+
await asyncio.wait(pending)
556+
557+
# Re-raise the exception
558+
raise task.exception()
559+
560+
# Collect results from all tasks
561+
results = [task.result() for task in tasks]
562+
563+
# Calculate totals
564+
total_entities = sum(r["entity_count"] for r in results)
565+
total_relations = sum(r["relation_count"] for r in results)
566+
processed_groups = len(results)
531567

532568
return {
533569
"groups_processed": processed_groups,
@@ -699,11 +735,8 @@ async def aprocess_graph_indexing(
699735
lightrag_logger=lightrag_logger,
700736
)
701737

702-
# 2. Find connected components in the extracted entities and relationships
703-
components = self._find_connected_components(chunk_results)
704-
705-
# 3. Process each component group with its own lock scope
706-
result = await self._process_entity_groups(chunk_results, components, collection_id)
738+
# 2. Process each component group with its own lock scope
739+
result = await self._grouping_process_chunk_results(chunk_results, collection_id)
707740

708741
# Count total results
709742
entity_count = sum(len(nodes) for nodes, _ in chunk_results)

aperag/graph/lightrag/operate.py

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
from collections import Counter, defaultdict
4242
from typing import Any, AsyncIterator
4343

44+
from aperag.concurrent_control import MultiLock, get_or_create_lock
45+
4446
from .base import (
4547
BaseGraphStorage,
4648
BaseKVStorage,
@@ -242,38 +244,86 @@ async def _merge_nodes_then_upsert(
242244
force_llm_summary_on_merge: int,
243245
lightrag_logger: LightRAGLogger | None = None,
244246
):
245-
"""Get existing nodes from knowledge graph use name,if exists, merge data, else create, then upsert."""
247+
"""
248+
Merge multiple entity nodes with the same name and upsert the result to knowledge graph.
249+
250+
This function handles entity deduplication by:
251+
1. Retrieving existing entity data from knowledge graph
252+
2. Merging existing data with new entity data
253+
3. Determining the final entity properties through aggregation
254+
4. Optionally using LLM to summarize lengthy descriptions
255+
5. Upserting the merged entity back to knowledge graph
256+
257+
Args:
258+
entity_name: The name of the entity to merge
259+
nodes_data: List of new entity data dictionaries to merge
260+
knowledge_graph_inst: Knowledge graph storage instance
261+
llm_model_func: LLM function for description summarization
262+
tokenizer: Tokenizer for text processing
263+
llm_model_max_token_size: Maximum token size for LLM input
264+
summary_to_max_tokens: Maximum tokens for summary output
265+
language: Language for LLM summarization
266+
force_llm_summary_on_merge: Threshold for triggering LLM summarization
267+
lightrag_logger: Optional logger instance
268+
269+
Returns:
270+
dict: The merged node data that was upserted
271+
"""
272+
273+
# 1. Initialize containers for collecting existing entity data
246274
already_entity_types = []
247275
already_source_ids = []
248276
already_description = []
249277
already_file_paths = []
250278

279+
# 2. Retrieve existing entity from knowledge graph if it exists
251280
already_node = await knowledge_graph_inst.get_node(entity_name)
252281
if already_node:
282+
# 2.1. Collect existing entity type
253283
already_entity_types.append(already_node["entity_type"])
284+
285+
# 2.2. Split and collect existing source IDs (multiple IDs separated by GRAPH_FIELD_SEP)
254286
already_source_ids.extend(split_string_by_multi_markers(already_node["source_id"], [GRAPH_FIELD_SEP]))
287+
288+
# 2.3. Split and collect existing file paths (multiple paths separated by GRAPH_FIELD_SEP)
255289
already_file_paths.extend(split_string_by_multi_markers(already_node["file_path"], [GRAPH_FIELD_SEP]))
290+
291+
# 2.4. Collect existing description
256292
already_description.append(already_node["description"])
257293

294+
# 3. Merge and determine final entity properties
295+
296+
# 3.1. Determine entity type by frequency count (most common type wins)
258297
entity_type = sorted(
259298
Counter([dp["entity_type"] for dp in nodes_data] + already_entity_types).items(),
260299
key=lambda x: x[1],
261300
reverse=True,
262301
)[0][0]
302+
303+
# 3.2. Merge descriptions with field separator, sorted and deduplicated
263304
description = GRAPH_FIELD_SEP.join(sorted(set([dp["description"] for dp in nodes_data] + already_description)))
305+
306+
# 3.3. Merge source IDs, deduplicated
264307
source_id = GRAPH_FIELD_SEP.join(set([dp["source_id"] for dp in nodes_data] + already_source_ids))
308+
309+
# 3.4. Merge file paths, deduplicated
265310
file_path = GRAPH_FIELD_SEP.join(set([dp["file_path"] for dp in nodes_data] + already_file_paths))
266311

267-
num_fragment = description.count(GRAPH_FIELD_SEP) + 1
268-
num_new_fragment = len(set([dp["description"] for dp in nodes_data]))
312+
# 4. Calculate description fragment counts for summarization decision
313+
num_fragment = description.count(GRAPH_FIELD_SEP) + 1 # Total description fragments
314+
num_new_fragment = len(set([dp["description"] for dp in nodes_data])) # New unique descriptions
269315

316+
# 5. Handle description summarization if there are multiple fragments
270317
if num_fragment > 1:
318+
# 5.1. Check if LLM summarization is needed based on fragment count threshold
271319
if num_fragment >= force_llm_summary_on_merge:
320+
# 5.1.1. Log LLM summarization decision
272321
if lightrag_logger:
273322
lightrag_logger.log_entity_merge(entity_name, num_fragment, num_new_fragment, is_llm_summary=True)
274323
else:
275324
logger.info(f"LLM merge N: {entity_name} | {num_new_fragment}+{num_fragment - num_new_fragment}")
276325

326+
# 5.1.2. Use LLM to summarize lengthy descriptions
277327
description = await _handle_entity_relation_summary(
278328
entity_name,
279329
description,
@@ -285,11 +335,13 @@ async def _merge_nodes_then_upsert(
285335
lightrag_logger,
286336
)
287337
else:
338+
# 5.2. Simple merge without LLM summarization (fragment count below threshold)
288339
if lightrag_logger:
289340
lightrag_logger.log_entity_merge(entity_name, num_fragment, num_new_fragment, is_llm_summary=False)
290341
else:
291342
logger.info(f"Merge N: {entity_name} | {num_new_fragment}+{num_fragment - num_new_fragment}")
292343

344+
# 6. Create final node data structure
293345
node_data = dict(
294346
entity_id=entity_name,
295347
entity_type=entity_type,
@@ -298,10 +350,14 @@ async def _merge_nodes_then_upsert(
298350
file_path=file_path,
299351
created_at=int(time.time()),
300352
)
353+
354+
# 7. Upsert the merged entity to knowledge graph
301355
await knowledge_graph_inst.upsert_node(
302356
entity_name,
303357
node_data=node_data,
304358
)
359+
360+
# 8. Add entity_name to returned data and return the final merged entity
305361
node_data["entity_name"] = entity_name
306362
return node_data
307363

@@ -445,6 +501,65 @@ async def _merge_edges_then_upsert(
445501

446502
@timing_wrapper("Merge & Update")
447503
async def merge_nodes_and_edges(
504+
chunk_results: list,
505+
component: list[str],
506+
workspace: str,
507+
knowledge_graph_inst: BaseGraphStorage,
508+
entity_vdb: BaseVectorStorage,
509+
relationships_vdb: BaseVectorStorage,
510+
llm_model_func,
511+
tokenizer,
512+
llm_model_max_token_size,
513+
summary_to_max_tokens,
514+
addon_params,
515+
force_llm_summary_on_merge,
516+
lightrag_logger: LightRAGLogger | None = None,
517+
) -> dict[str, int]:
518+
"""Merge nodes and edges from extraction results
519+
520+
Args:
521+
chunk_results: List of (nodes_dict, edges_dict) tuples
522+
component: List of entity names in this component (for locking)
523+
workspace: Workspace identifier for lock creation
524+
knowledge_graph_inst: Knowledge graph storage
525+
entity_vdb: Entity vector database
526+
relationships_vdb: Relationships vector database
527+
llm_model_func: LLM function
528+
tokenizer: Tokenizer
529+
llm_model_max_token_size: Max token size for LLM
530+
summary_to_max_tokens: Max tokens for summary
531+
addon_params: Additional parameters
532+
force_llm_summary_on_merge: Force LLM summary threshold
533+
lightrag_logger: Optional logger
534+
535+
Returns:
536+
Dict with entity_count and relation_count
537+
"""
538+
539+
# Create locks for all entities in this component
540+
entity_locks = []
541+
for entity_name in sorted(component): # Sort to prevent deadlock
542+
lock = get_or_create_lock(f"entity:{entity_name}:{workspace}")
543+
entity_locks.append(lock)
544+
545+
# Use MultiLock to acquire all locks for this component
546+
async with MultiLock(entity_locks):
547+
return await _merge_nodes_and_edges_impl(
548+
chunk_results,
549+
knowledge_graph_inst,
550+
entity_vdb,
551+
relationships_vdb,
552+
llm_model_func,
553+
tokenizer,
554+
llm_model_max_token_size,
555+
summary_to_max_tokens,
556+
addon_params,
557+
force_llm_summary_on_merge,
558+
lightrag_logger,
559+
)
560+
561+
562+
async def _merge_nodes_and_edges_impl(
448563
chunk_results: list,
449564
knowledge_graph_inst: BaseGraphStorage,
450565
entity_vdb: BaseVectorStorage,
@@ -456,8 +571,8 @@ async def merge_nodes_and_edges(
456571
addon_params,
457572
force_llm_summary_on_merge,
458573
lightrag_logger: LightRAGLogger | None = None,
459-
) -> None:
460-
"""Merge nodes and edges from extraction results"""
574+
) -> dict[str, int]:
575+
"""Internal implementation of merge_nodes_and_edges"""
461576

462577
# Collect all nodes and edges from all chunks
463578
all_nodes = defaultdict(list)
@@ -539,6 +654,10 @@ async def merge_nodes_and_edges(
539654
}
540655
await relationships_vdb.upsert(data_for_vdb)
541656

657+
entity_count = len(entities_data)
658+
relation_count = len(relationships_data)
659+
return {"entity_count": entity_count, "relation_count": relation_count}
660+
542661

543662
@timing_wrapper("Entity Extraction")
544663
async def extract_entities(

0 commit comments

Comments
 (0)