Eight Articles Built the Ship. Now Let's Sail It.
If you've followed the series so far, your working memory should hold a clear checklist:
- Article 03: AST function-level chunking + vector retrieval, Recall@5 = 0.958 — the ceiling for text-only approaches
- Article 05: Graph retrieval rescues Q8, but BFS noise breaks Q1 — it's a one-for-one trade
- Articles 06 & 07: Structure-aware embedding and hybrid search can't actually bridge the gap
- Article 08: Three orthogonal paths — vector for semantics, graph for structure, symbol for exact matches — route by query intent
All of that was measured on a 30-function toy codebase. The toy was honest. But it was still a toy.
Today we change the venue.
LightRAG is one of the highest-starred open-source knowledge graph RAG frameworks on GitHub. The codebase has been fully indexed by codebase-memory-mcp: 20,674 nodes, 94,517 edges, covering 409 Python files and 101 TypeScript files (Web UI). That's a medium-scale real project — large enough to make everything from the earlier experiments matter.
This article's job is straightforward: take three real questions, fire each one down the appropriate retrieval path, and spread the results flat so you can see exactly what each path delivers.
A Quick Map Before We Start
Before firing queries, one orientation pass.
LightRAG's name might imply a simple retrieval utility, but the actual code structure is considerably richer. Running get_architecture reveals the most important signal first: layer boundaries.
entry layer: kg/, chunker/ ← storage adapters, chunkers
core layer: base, api, parser ← abstract base classes, REST API, parsers
internal: examples/, tests/ ← examples, tests (not exported)
This immediately tells you: if you want to understand 'how does LightRAG process a document,' the main arena is lightrag/, the entry point is lightrag.py, and storage implementations live in kg/ (10+ backends: Neo4j, MongoDB, Qdrant, Milvus, PostgreSQL, ...).
Now for the retrieval runs.
Path 1: Vector — Ask in Natural Language, Get a Concept Entry Point
Question: What retrieval modes does LightRAG support, and when should each one be used?
This is a classic capability query: the caller doesn't know what the relevant function or class is called — they just know what they want to understand. This is exactly where the vector path excels.
Tool: search_graph (BM25 + vector dual index)
query: "query search hybrid retrieval mode"
label: Method
limit: 5
Top result (the rest are test files):
QueryParam (lightrag/base.py, line 83)
"local": Focuses on context-dependent information.
"global": Utilizes global knowledge.
"hybrid": Combines local and global retrieval methods.
"naive": Performs a basic search without advanced techniques.
"mix": Integrates knowledge graph and vector retrieval.
"bypass": ...
One query, straight to the source:
class QueryParam:
"""Configuration parameters for query execution in LightRAG."""
mode: Literal["local", "global", "hybrid", "naive", "mix", "bypass"] = "mix"
All six retrieval modes, right in the QueryParam dataclass. The docstring explains each one:
| Mode | Description |
|---|---|
local |
Local context retrieval, focuses on the entity neighborhood around the query |
global |
Global knowledge retrieval, aggregates relationships across documents |
hybrid |
local + global combined |
naive |
Plain vector search, no knowledge graph |
mix |
Knowledge graph + vector fusion (the default) |
bypass |
Pass directly to LLM, skip retrieval entirely |
Notice what was returned: a class definition, not a function implementation. The vector path is highly effective at 'find the concept entry point' queries — you describe a feature, it returns the most relevant abstraction. From there you drill down.
A second search_graph call confirmed the pattern:
query: "insert document knowledge graph extraction"
→ hit: _find_related_text_unit_from_entities (operate.py:5260)
_find_related_text_unit_from_relations (operate.py:5511)
run_rebuild_entities_relations (tools/rebuild_vdb.py:900)
The intent 'insert documents, extract knowledge graph' navigated directly to the extraction logic in operate.py — not a pile of results that happen to contain the word 'insert'.
Path 2: Graph — Follow the Call Chain, Understand What a Function Triggers
Question: What does ainsert() call? What is the full execution path for document ingestion?
This is a structural query: the caller knows the entry point name and wants to understand the complete execution path downstream. The graph path owns this category.
Tool: trace_path (call graph BFS)
function_name: ainsert
mode: calls
direction: outbound
depth: 2
Returns (hop=1 direct callees, hop=2 indirect):
hop=1 direct:
apipeline_enqueue_documents (pipeline.py)
apipeline_process_enqueue_documents (pipeline.py)
generate_track_id (utils.py)
resolve_chunk_options (parser/routing.py)
hop=2 indirect (inside pipeline):
_run_pipeline_batch
_validate_and_fix_document_consistency
_atomic_release_busy_or_consume_pending
compute_mdhash_id
sanitize_text_for_encoding
normalize_document_file_path
filter_keys (BaseKVStorage)
upsert (BaseVectorStorage)
get_by_id (BaseVectorStorage)
get_docs_by_statuses (DocStatusStorage)
get_namespace_data
get_namespace_lock
... (36 nodes total)
These 36 nodes form the complete document ingestion path. But the node list alone isn't the full story — the source code is:
async def ainsert(
self,
input: str | list[str],
split_by_character: str | None = None,
...
) -> str:
"""Async insert documents with checkpoint support (fixed-token chunking only).
SDK convenience entry point. It **always** chunks with the fixed-token
(F) strategy: ``process_options`` is intentionally not passed, so the
document runs the F chunker. ...
The LightRAG **server / REST API does not call this method** — it
ingests via :meth:`apipeline_enqueue_documents` +
:meth:`apipeline_process_enqueue_documents` with a per-document
``process_options`` selector, which is how F/R/V/P are chosen there.
"""
chunk_opts = resolve_chunk_options(
self.addon_params,
split_by_character=split_by_character,
split_by_character_only=split_by_character_only,
)
await self.apipeline_enqueue_documents(input, ids, file_paths, track_id, chunk_options=chunk_opts)
await self.apipeline_process_enqueue_documents()
return track_id
There is a critical design decision buried in this source that you would never see from the function name alone:
ainsertonly supports the fixed-token chunking strategy (F strategy). If you want recursive-character (R), semantic-vector (V), or paragraph-semantic (P) chunking, you cannot callainsert— you must callapipeline_enqueue_documents+apipeline_process_enqueue_documentsdirectly with an explicitprocess_options.
Equally: LightRAG's REST API server doesn't call ainsert either — it goes directly to the pipeline layer. So SDK users and REST API users are actually running different code paths.
This is what the graph path uniquely delivers: not just 'here is a function,' but 'here is what role this function plays in the system.' A vector search would probably return ainsert for 'how to insert documents.' But it wouldn't tell you that this method is intentionally designed as a simplified F-only entry point, and what that means for how you should actually use it.
Path 3: Symbol — Precise Hit, Zero Ambiguity
Question: Which parts of the codebase call BaseVectorStorage.upsert?
This is an impact analysis query: the caller wants to do a refactor or security audit and needs to know every caller of a given interface. No semantic understanding is required — only precise matching.
Tool: search_code (graph-augmented grep)
pattern: "BaseVectorStorage"
mode: compact
limit: 5
Results show upsert (the core method of BaseVectorStorage) has fan_in = 268 — one of the most heavily called methods in the entire project.
A more targeted query:
pattern: "QueryParam"
Immediately locates:
QueryParam lightrag/base.py:83 (Class, in_degree=19)
↑ called by 19 locations:
- lightrag/lightrag.py:2056 (method: query)
- lightrag/lightrag.py:2091 (method: aquery)
- lightrag/operate.py:4323 (_perform_kg_search)
- lightrag/lightrag.py:2344 (aquery_llm)
...
19 callers, all precisely located with file and line number. If you're modifying QueryParam's interface — deprecating a mode, adding a parameter — this list is your blast radius assessment.
The symbol path is graph-augmented grep. Plain grep returns where a string appears. The symbol path also tells you each hit's position in the call graph — whether it's an entry point, who calls it, how high its in-degree is. That's enough to answer 'how big a change is this?' in seconds.
All Three in Combination: One Real Engineering Task, Three Perspectives
The three examples above demonstrated each path independently. Now, a scenario closer to real engineering:
Task: Understand LightRAG's full document ingestion flow, in order to modify the chunking strategy
This is a question an engineer would ask before making a feature change. The answer isn't in any single function — it needs multiple perspectives assembled into a map.
Step 1: Vector path — locate the concept entry points
search_graph("document chunking strategy pipeline insert")
→ ainsert (lightrag.py:1428)
→ ainsert_custom_chunks (lightrag.py — deprecated)
→ resolve_chunk_options (parser/routing.py)
The vector path reveals 'which entry points in this system relate to chunking,' and along the way flags that ainsert_custom_chunks is deprecated.
Step 2: Graph path — trace the execution chain
trace_path("ainsert", mode=calls, depth=2)
→ hop=1: apipeline_enqueue_documents → resolve_chunk_options
→ hop=2: _run_pipeline_batch → [various Storage.upsert]
The graph path shows: chunking strategy selection happens in resolve_chunk_options (before enqueue), actual chunking happens in _run_pipeline_batch (pipeline execution), and the result gets written to multiple storage backends. If you want to change chunking strategy, modify parser/routing.py — not ainsert in lightrag.py.
Step 3: Symbol path — pin the interface definition
search_code("resolve_chunk_options")
→ lightrag/parser/routing.py:chunk_strategy_key
→ lightrag/parser/routing.py:slim_chunk_options
→ lightrag/parser/routing.py:default_chunker_config
The symbol path pins three related functions in routing.py, with their exact reference locations in the ainsert call chain.
After three steps you know: change chunking strategy in parser/routing.py; ainsert via SDK only supports F strategy, all others require calling the pipeline layer directly; storage writes are abstracted through BaseVectorStorage.upsert, so chunking changes won't affect storage backends. That's a complete engineering change map — each path contributed exactly what it's best at.
The Counter-Intuitive Finding: Scale Makes the Graph Path More Valuable
On the toy codebase (30 functions), trace_path expanded to a handful of nodes — the graph path's value wasn't obvious. On LightRAG (7,761 functions + 3,569 methods), the same call trace expanded to 36 nodes covering the complete path from user API to storage abstraction.
This isn't linear growth — it's a scale amplification effect: the larger the codebase, the richer any function's neighborhood, the harder it becomes for pure vector retrieval to surface structural relationships, and the greater the relative advantage of the graph path.
This is the Q8 problem from Article 05 reflected in a real codebase. ainsert and _run_pipeline_batch are semantically distant — 'high-level document insertion API' vs 'low-level batch pipeline executor' — no embedding model would bring those two close. But the call graph connects them in one hop.
When the question is 'what would change if I modify this?' or 'who calls this function?' — those questions have the same answer structure in a 5,000-function codebase as in a 30-function one: the answer is only in the graph.
Summary
Nine articles in, this series has tracked a single thread from first principles to production tooling: measure the ceiling of each single-path approach, identify where each fails, then design around the failure modes.
A final statement of where each path belongs:
-
Vector path: You don't know what the function is called, just what it does —
search_graph(query=...)is the first move -
Graph path: You know the entry point, want to understand its execution chain and blast radius —
trace_pathis the workhorse -
Symbol path: You know the exact function or class name, want its definition and all references —
search_codereturns results in under a second
These aren't competing approaches. They're orthogonal dimensions. Any task that requires 'understand an unfamiliar codebase' needs all three to build a complete cognitive map.
The central conclusion, one more time:
Codebase semantic understanding is multi-dimensional. Every single signal has a blind spot. Pick the right tool, keep the paths distinct — that's what engineering-quality retrieval actually looks like.
For more details on codebase-memory-mcp and its full feature set, visit the GitHub repository.
Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.
Find more useful knowledge and interesting products on my Homepage
Top comments (0)