
做代码库问答和文档问答有一个很明显的差别一份技术文档可以按段落切块后直接放进向量库效果往往已经够用但一套源码里一个函数只有几十行却可能被几十个地方调用它的“含义”是由调用方、被调用方、继承关系共同决定的。code-graph-rag这种项目要解决的正是这个问题把源码解析成一张可以查询的代码知识图然后基于图结构做检索增强生成。这篇文章会围绕code-graph-rag的思路把从源码解析、建图、向量索引到本地问答的完整链路展开读者可以拿一个自己的小仓库按步骤跑通。这类项目放到 RAG 技术脉络里属于 GraphRAG 在代码场景的落地实践。传统 RAG 解决的是“在非结构化文本里找答案”而代码既是文本又是带有强依赖关系的结构化对象。只做向量检索会丢掉调用链和继承关系只做图查询又缺少语义召回所以code-graph-rag的核心技术主线是解析代码生成图谱再用图结构修正 RAG 的召回和生成。下面从原理开始拆解。1. 为什么代码问答需要图结构而不是纯向量检索1.1 普通 RAG 的切块策略在代码场景下为什么失效普通 RAG 的处理流程一般是把文档切块、做 embedding、存向量库、查询时用向量相似度召回 TopK 文本块。这个流程对自然语言文档有效因为文档段落之间的依赖关系比较弱读者靠上下文就能理解。代码则完全不同。比如一个登录接口login调用同项目另一个文件里的validate_token如果按固定长度切块很容易出现两种情况login函数被切到两个块里函数头在一个块函数体在另一个块login和validate_token的语义并不相似但它们之间存在调用关系。第二种情况是普通向量检索最容易漏掉的。用户问“修改validate_token会影响哪些地方”向量检索只能召回与问题文本相似的内容它并不知道login调用了validate_token更不知道还有多少地方间接依赖这条调用链。还有一个问题是代码里到处都是重复的框架样板。把from xxx import yyy、if __name__ __main__这类内容作为向量切块后它们会制造大量低价值文本块既浪费存储又会污染相似度排序结果。代码问答需要的不是“相似的文本片段”而是“结构上相关的代码实体”。1.2 图的本质让检索从“找相似文本”变成“找相关结构”code-graph-rag的基本假设是代码库可以表示为一张有向图节点是代码实体边是实体之间的关系。GraphRAG 在通用文档场景里把文档处理成“实体-关系”三元组这里则把代码解析成“符号-关系”图。一张代码知识图至少包含这些元素文件节点保存文件路径、语言类型、整体职责描述类节点保存类名、父类、注解或装饰器函数节点保存函数名、参数、返回值、函数体摘要模块导入关系例如A import B调用关系例如A.func calls B.func继承关系例如A extends B引用关系例如A 引用了 B 的常量或工具函数。建图之后RAG 的检索不再只是“把问题向量化后找相似块”而是先找到种子节点再沿着边扩展出邻居、两跳关系甚至整条调用链。这一步把代码问答从“语义匹配”提升到了“结构推理”。1.3 节点和关系设计直接决定问答效果图建得太粗问答就退化成了文件级检索建得太细图会爆炸且大量低价值节点会干扰检索。实际项目里建议先按“文件、类、函数”三个粒度建节点再按需要补充变量、常量、接口、测试用例等节点。节点粒度适合回答的问题不建议回答的问题文件这个模块是干什么的某个函数被谁调用类这个类的职责和使用方式validate_token在哪定义函数某个函数的入参、返回值和调用方整个系统的模块划分变量/常量某个配置项出现在哪些地方架构层面的影响分析节点的属性也不只包括名称。建议在解析阶段同时生成每个节点的“签名摘要”比如函数参数、返回类型、一行代码职责描述。这些短摘要可以作为 embedding 的输入文本让语义检索和结构检索共用一个节点索引。关系边的类型需要用枚举管理不要用自由字符串否则后续写图查询时会很难维护。2. 构建代码知识图的完整流程2.1 从源码到 AST 再到符号表建图的第一步是把源码转换成结构化数据。推荐用 tree-sitter 这类增量解析器做第一步因为它能准确识别函数、类、方法、导入语句的边界错误容忍度也比正则表达式高很多。解析流程通常是按扩展名识别语言选择对应的 tree-sitter grammar对整个文件生成 AST遍历 AST提取类、函数、导入、赋值等节点为每个节点生成全局唯一的符号 ID例如module/file.py::ClassName::method_name记录节点所在文件、起止行号、源码片段和生成摘要。这里的符号表是后续所有索引的基础。符号 ID 必须稳定因为后面做增量更新时要判断一个函数是新增、修改还是删除。2.2 建立调用、继承和引用关系AST 只能给出单个文件内部的语法结构跨文件关系需要再做一层分析。常见做法是通过 import 语句建立文件或模块之间的依赖在函数调用点解析被调用方的限定名对类定义解析父类引用识别装饰器、注解和配置映射关系。静态解析做不到 100% 准确。比如 Python 里的getattr(obj, method_name)、Java 的反射调用、JavaScript 的动态属性访问都无法在静态分析阶段确定目标。实际项目的做法是能解析的关系先解析解析不了的关系通过文本相似度做候选召回或在生成阶段明确提示模型“以下调用链可能不完整”。2.3 存储图数据库与向量索引配合建好图之后需要同时保存两份数据图结构和节点语义向量。图结构可以放在 Neo4j 这类专业图数据库也可以放在 NetworkX 这类内存图结构里。两者差别很明显存储方案优点缺点适用场景Neo4j支持 Cypher 查询、事务、增量更新、权限控制部署复杂度高License 和运维成本需要评估上百个服务、多人协作的长期系统NetworkX无额外服务示例代码简单适合学习和实验数据量大后内存压力大无持久化能力演示项目、小仓库、本地原型自定义关系表SQL与现有业务系统容易集成多跳递归查询要手写 SQL 或多次查询已有业务数据库不想再维护图数据库向量索引可以用独立的向量数据库也可以用支持向量索引的图数据库插件。关键点在于向量索引里的每一条记录都必须保存节点 ID这样语义检索命中的结果才能回溯到图中的节点继续做邻居扩展。2.4 检索时如何把文本相关性和图上下文结合code-graph-rag的检索阶段通常采用两阶段策略。第一阶段做混合召回把问题向量化在节点向量索引中召回 TopK 个候选节点同时把问题里的关键词、标识符、类名、函数名提取出来在图里做模糊匹配。第二阶段做图扩展从第一阶段的候选节点出发查询一到两跳邻居得到调用方、被调用方、相关类和相关文件。然后把「问题 种子节点 图上下文」组装成提示词交给大模型生成回答。这种两阶段策略能同时兼顾语义相关性和结构完整性。比如用户问“我想加一个记住登录状态的功能应该改哪些文件”语义召回可能命中某个包含“remember”注释的函数图扩展则能顺藤摸瓜找出所有涉及登录鉴权链路的函数。3. 本地运行 code-graph-rag 的最小环境与准备3.1 依赖组件清单由于code-graph-rag这类仓库的依赖版本变化较快下面给出的组件清单用于说明一套最小可运行环境需要哪些部分。落地前要结合仓库 README 和本机环境确认版本。组件作用建议Python运行解析、建图、API 服务3.10 或以上tree-sitter解析多语言源码按目标语言安装对应 grammarNetworkX 或 Neo4j保存代码知识图学习环境先用 NetworkX生产再用 Neo4jembedding 模型生成节点和问题的向量可用本地模型也可用已有向量服务大模型推理服务生成问答结果本地建议用 llama.cpp 加载 Qwen2-7B 指令版量化模型FastAPI提供问答 HTTP 接口3.x 版本即可如果希望完全本地运行推荐组合是llama.cpp Qwen2-7B-Instruct 量化版 FastAPI。这个组合不需要申请外部接口只要机器内存和显存足够就能把数据留在本地处理。3.2 最小项目结构一个最小实现可以按下面目录组织code-graph-rag/ ├── extractor/ │ ├── parser.py # tree-sitter 解析源码 │ ├── symbols.py # 符号定义和 ID 生成 │ └── relations.py # 调用、导入、继承关系抽取 ├── graph/ │ ├── builder.py # 把解析结果写入图结构 │ └── query.py # 图查询和邻居扩展 ├── index/ │ ├── embedder.py # 节点摘要向量化 │ └── vector_store.py # 向量索引读写 ├── server/ │ ├── app.py # FastAPI 入口 │ └── prompt.py # 提示词模板 ├── data/ # 测试代码仓库 ├── test_queries.md └── README.md这只是示例结构。实际项目要根据自己的包名和代码习惯调整但“解析层、图存储层、向量索引层、服务层”这四层边界尽量保留因为后面排查问题会非常依赖分层。3.3 准备一份测试代码仓库学习阶段不要直接拿复杂微服务工程试。先用一个十几文件的小工具仓库最好包含跨文件调用、类继承和少量公共工具函数。下面给一个最简单的 Python 示例方便验证图关系# auth/service.py from common.utils import validate_token class AuthService: def login(self, username: str, password: str) - str: token self.create_token(username) return token def create_token(self, username: str) - str: return ftoken-{username}# common/utils.py def validate_token(token: str) - bool: return token.startswith(token-)这个例子虽然简单但已经包含两条关键关系auth/service.py导入了common/utils.pyAuthService.login调用了create_token同时文件之间通过 import 建立依赖。跑通这个例子后再换更大的仓库就能检查图扩展逻辑是否正确。4. 核心实现思路与关键配置4.1 解析代码生成图数据下面用 tree-sitter 解析 Python 函数定义代码用于说明思路实际 API 版本可能不同from tree_sitter import Language, Parser import tree_sitter_python PARSE_LANGUAGE Language(tree_sitter_python.language()) parser Parser(PARSE_LANGUAGE) def extract_functions(source: str): tree parser.parse(source.encode(utf-8)) root tree.root_node functions [] def walk(node, parent_classNone): if node.type function_definition: name_node node.child_by_field_name(name) if name_node: functions.append( { name: name_node.text.decode(), class: parent_class, start: node.start_point[0] 1, end: node.end_point[0] 1, source: source[node.start_byte:node.end_byte], } ) if node.type class_definition: class_node node for child in node.children: walk(child, parent_classclass_node.child_by_field_name(name).text.decode()) else: for child in node.children: walk(child, parent_classparent_class) walk(root) return functions这里要区分“类方法”和“普通函数”因为同名的类方法在不同类里是完全不同的符号。符号 ID 可以定义为文件路径::类名::函数名没有类的函数就退化为文件路径::函数名。这个 ID 决定后面去重、更新和查询是否能对准节点。4.2 图数据写入内存图或 Neo4j学习阶段用 NetworkX 最省事import networkx as nx graph nx.MultiDiGraph() def add_function_node(symbol_id, file_path, summary): graph.add_node( symbol_id, kindfunction, filefile_path, summarysummary, ) def add_call_relation(caller_symbol, callee_symbol): graph.add_edge(caller_symbol, callee_symbol, relationCALLS)MultiDiGraph允许两个节点之间存在多条不同类型的边方便后续加IMPORTS、INHERITS、REFERENCES。如果生产环境要用 Neo4j可以导入同样的边数据CREATE (f:Function {id: $id, name: $name, file: $file, summary: $summary}) CREATE (f)-[:CALLS]-(t)Cypher 查询两跳调用链非常直观MATCH (f:Function {name: login})-[:CALLS*1..2]-(target:Function) RETURN DISTINCT target.name, target.file4.3 对代码块做向量化并与图节点绑定图节点建立后还需要一个字段用于语义检索节点摘要。摘要可以是“函数签名 一句话注释 源码片段”长度控制在 200 到 500 个字符。切块参数可以参考下面这张表它不是绝对的要根据 embedding 模型的上下文长度调整参数含义常见值调大影响调小影响chunk_size单段文本长度400-800 字符语义更完整检索粒度变粗定位精确但上下文易被截断chunk_overlap相邻块重叠长度50-100 字符减少信息丢失索引体积变大边界信息容易丢top_k语义召回节点数5-20召回全噪声多响应快容易漏关键节点graph_depth图邻居扩展层数1-2影响分析更全面查询变慢只能覆盖直接依赖向量存储里每条记录保存symbol_id而不是只保存文本。查询命中向量后用symbol_id回到图里查邻居这样才能把语义相关变成结构相关。4.4 问答阶段的检索与提示词组织本地问答服务可以用 FastAPI 写一个最简接口from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class AskRequest(BaseModel): query: str class AskResponse(BaseModel): answer: str context_nodes: list[str] app.post(/ask, response_modelAskResponse) def ask(req: AskRequest): seed_nodes vector_store.search(req.query, top_k10) context_nodes graph_query.expand(seed_nodes, depth2) answer llm_chain.run( questionreq.query, contextformat_context(context_nodes), ) return AskResponse(answeranswer, context_nodescontext_nodes)提示词模板建议明确告诉模型两类信息一是代码节点清单二是节点之间的关系。不要只给源码片段否则又退化成普通 RAG。一个简单的组织方式基于以下代码图谱节点回答问题。 节点之间可能存在 CALLS、IMPORTS、INHERITS 关系。 如果调用链不完整请明确说明。 节点 {context} 问题 {query}本地大模型服务可以用 llama.cpp 启动llama-server \ -m qwen2-7b-instruct-q4_k_m.gguf \ --host 127.0.0.1 \ --port 8080 \ -c 8192如果当前版本的 llama.cpp 仍在build/bin下提供旧的可执行文件server把llama-server换成对应路径即可。模型加载后问答服务通过 HTTP 调用这个端口就能做到请求代码、检索图谱、生成回答全链路本地运行。5. 运行验证与结果分析5.1 启动服务并建立索引按顺序执行下面几步# 1. 解析测试代码仓库生成图数据 python -m extractor.parser --repo data/my_demo # 2. 建立向量索引 python -m index.build --node-store graph_nodes.json # 3. 启动本地模型服务 llama-server -m qwen2-7b-instruct-q4_k_m.gguf --host 127.0.0.1 --port 8080 -c 8192 # 4. 启动问答 API uvicorn server.app:app --host 127.0.0.1 --port 8000每一步都要有检查点。第 1 步结束后检查输出的 JSON 里是否包含预期函数、类、导入边第 2 步结束后检查向量库条数是否与节点数量一致第 4 步启动成功后先访问http://127.0.0.1:8000/docs确认 FastAPI 服务正常。5.2 提问测试使用 curl 测试一个典型问题curl -X POST http://127.0.0.1:8000/ask \ -H Content-Type: application/json \ -d {query: 修改 validate_token 会影响哪些函数}对前面极小的示例仓库来说正常回答应该包含AuthService.login和create_token并且回答会说明影响路径是validate_token - login。如果回答只给出validate_token自己的定义说明图扩展阶段没有生效要去查调用边是否建立成功。5.3 对比纯向量 RAG 的效果差异把同样的测试数据跑一组对照实验一组只做向量切块检索一组使用code-graph-rag的图扩展。对比项至少包括测试问题纯向量 RAGcode-graph-rag某个函数定义在哪个文件能回答能回答某个函数被哪些地方调用依赖代码注释容易漏图查询直接列出调用方修改 A 影响哪些模块难以回答调用链和导入链可推导两个函数的调用层级靠运气命中多跳查询可复现这个对照实验建议保留成测试用例因为后续改索引策略或图结构时能快速判断效果是变好还是变差。6. 常见问题排查6.1 解析不到符号或跨文件引用现象索引里缺了一部分函数或者调用边明显缺失。可能原因tree-sitter grammar 未匹配实际语言版本代码是通过装饰器、代码生成或反射动态定义的import 别名和目标符号的映射没有处理。排查顺序先用可视化或调试输出检查 AST 里是否出现目标节点类型单独解析一个包含跨文件 import 的文件确认关系抽取逻辑检查符号 ID 是否包含完整文件前缀避免同名函数互相覆盖。处理建议给解析器加失败日志记录没有识别出的文件类型和语法结构动态调用关系先用“候选调用”标记不要直接丢弃。6.2 图查询耗时过大现象增加graph_depth后响应变慢甚至超时。可能原因图的出边和入边太多尤其是工具函数会被大量组件调用导致邻居数量指数级增长。检查方式查询热点节点统计看哪些函数被超过 100 个节点调用。处理建议限制单层邻居上限先做向量召回缩小种子范围对公共工具函数单独做摘要节点不让每个调用方都展开完整实现。6.3 检索结果和代码上下文对不上现象大模型回答里提到一个函数但该函数并不是当前检索链路里的活跃节点。可能原因embedding 模型对不同语言符号的区分度不稳定或者提示词模板里没有约束模型只能使用提供节点。处理建议在提示词里加硬约束“只能基于给定节点回答”回答中引用节点时要求给出符号 ID在 API 响应里返回context_nodes方便定位是哪一步检索出了问题。6.4 大模型回答引用了错误文件现象回答内容合理但提到的文件路径或行号与真实代码不符。可能原因图节点摘要里包含过多代码噪音模型被相似代码误导或者向量召回的 TopK 中混入了多个相似函数。检查方式打印context_nodes看真实传给模型的节点是否已经包含错误文件。处理建议在节点摘要中加入“文件路径 符号 ID 签名”作为前缀提高区分度生产环境可以把 rerank 模型加入链路对召回结果做二次排序。7. 生产落地建议与扩展方向7.1 从实验到生产要补的基础能力code-graph-rag在本地小仓库跑通只是第一步进入生产环境还要补齐这些能力配置外置化包括 llm 地址、embedding 模型、图数据库连接串、索引路径日志和监控至少记录解析耗时、索引数量、检索耗时、生成耗时权限控制代码图谱和提示词涉及业务源码时要控制访问范围和审计回滚机制索引或图数据更新后要能切回上一版本资源评估图节点量和向量量增长后内存、磁盘和显存都要重新评估。7.2 增量更新与多分支处理生产代码库变化很快每次全量重建既慢又浪费资源。建议把解析结果按文件哈希做增量判断文件没变就复用旧节点文件变了才重新解析并更新相关边。多分支并行时可以按分支名建独立的命名空间避免不同分支的符号互相覆盖。这个问题不解决问答服务上线后维护成本会比开发成本还高。7.3 和 Agent、MCP 结合后的进一步扩展code-graph-rag的图结构本身是一种比较理想化的“外部工具”。把它封装成 MCP 工具后Agent 可以在不提前知道答案的情况下按需调用先查调用链再读源码再改代码最后生成说明。这个方向比单纯的问答更进一步也是 RAG 从“被动回答问题”向“主动完成任务”演进时GraphRAG 相比普通向量 RAG 更有优势的地方。同时可以引入重排模型优化召回顺序并在回答里保留引用溯源输出每个结论对应的符号 ID、文件路径和调用路径。引用溯源做得越细问答结果的可信度越高调试成本也越低。这套能力做完后code-graph-rag就不再只是一个问答 Demo而是可以嵌入开发流程的代码理解基础设施。对一个刚开始做代码 GraphRAG 的团队来说先用小仓库把解析、建图、检索、生成四层链路跑通再逐步替换存储方案和处理更大规模代码库是比较稳妥的路线。