08-代码仓库RAG全链路-从Gitee抓取AST分块到跨库检索的工程深潜

7 阅读13分钟

代码仓库 RAG 全链路:从 Gitee 抓取、AST 分块到跨库检索

信息流产品AI助手系列 · 第八篇

当 RAG 系统的知识来源从文档扩展到源代码,整个数据链路与检索链路都需要重新设计。代码不是文本——它有语法结构、调用关系、继承体系,不能简单地"切块-向量化-召回"。本文深入拆解代码仓库从导入到检索的完整工程链路,记录每一步的设计决策与踩坑过程。


📚 知识库项目实战系列:

  1. RAG 系统架构设计:基于 LangGraph 的多源异构 RAG 系统
  2. 多路召回与融合排序:RRF + Rerank + 动态 TopK 工程实践
  3. 跨库链路检索:Neo4j 图数据库桥接文档与代码
  4. 知识库项目实战 | RAG 检索链路实战:从 ItemName 过滤陷阱到引用去重的工程修复
  5. HyDE 假设性文档检索:原理、陷阱与工程化反思
  6. RAG 系统的可控性设计:从 HyDE 到联网搜索的按需启停架构
  7. 从知识库到信息流产品AI助手:多源数据血缘构建与全链路溯源
  8. 知识库项目实战 | 代码仓库 RAG 全链路:从 Gitee 抓取、AST 分块到跨库检索 ← 本篇

一、架构总览:两条链路,四种来源

整个系统分为导入链路检索链路,代码仓库在两条链路中的流转路径如下:

【导入链路】
Gitee API → GitFetcher 抓取 → ASTParser 符号解析 → CodeChunker 分块
    ├── Qdrant kb_code 集合(向量索引)
    └── Neo4j 代码图谱(符号节点 + 关系边)

【检索链路】
用户查询
    ├── 普通路径:向量检索 kb_code → RRF 融合 → Rerank
    └── 跨库路径:文档检索 → Neo4j 桥接 → 代码取回(或 LLM 回退)
                        ↓
                    RRF → Rerank → Answer Output → 前端引用卡片

代码仓库与本地文档、Gitee 在线文档共享同一条检索管线,但在存储层来源溯源层完全隔离——这是整套设计最重要的架构决策。


二、导入链路:从 Gitee 到双写存储

2.1 GitFetcher:Gitee API 的文件树抓取

GitFetcher 是代码导入的入口。它通过 Gitee REST API 实现仓库文件树的递归抓取,核心流程:

解析仓库 URL → 解析分支 SHA → 递归获取文件树 → 按语言过滤 → 逐文件拉取内容(Base64 解码)

分支解析的三级降级

Gitee API 获取分支 SHA 时,遇到过分支名与默认分支不一致导致的 404 问题。为此设计了三级降级策略:

def _resolve_ref_sha(self, owner, repo, ref):
    # 1. 直接请求 /branches/{ref}
    try:
        branch_info = self._request("GET", f"/repos/{owner}/{repo}/branches/{ref}")
        return branch_info["commit"]["sha"]
    except HTTPError as e:
        if e.response.status_code != 404:
            raise

    # 2. 回退:从仓库元数据获取 default_branch,再查一次
    repo_info = self._request("GET", f"/repos/{owner}/{repo}")
    default_branch = repo_info.get("default_branch", "master")
    # ... 用 default_branch 再试一次

    # 3. 最后兜底:列出所有分支,逐一匹配
    branches = self._request("GET", f"/repos/{owner}/{repo}/branches")
    for b in branches:
        if b["name"] in (ref, default_branch):
            return b["commit"]["sha"]

这三级降级的设计源于真实场景:用户输入的分支名可能是 mainmaster、或者某个特性分支,甚至可能根本不存在。逐级降级保证了最大程度的容错。

限流控制

Gitee API 有调用频率限制,逐文件拉取时加入了 time.sleep(0.1) 的节流。一个中型仓库(200个代码文件)的完整抓取需要约20秒的额外等待,换来的是零限流报错。

2.2 ASTParser:多语言符号提取

代码分块的前提是理解代码结构。ASTParser 针对不同语言采用不同的解析策略:

语言解析方式提取内容
Pythonast 模块真解析函数/类/方法签名、docstring、装饰器、代码体、行号
JavaScript/TypeScript正则表达式函数声明、箭头函数、类声明
Java正则表达式类声明、方法声明(过滤关键字)
Go正则表达式func 声明、struct、interface
其他语言通用正则兜底函数/类的粗略匹配

Python 解析的核心实现

Python 使用标准库 ast 模块进行真正的语法树解析:

def _parse_python(self, code, file_path):
    tree = ast.parse(code)
    symbols = []

    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            # 提取参数列表
            args = [a.arg for a in node.args.args]
            # 提取 docstring
            docstring = ast.get_docstring(node) or ""
            # 提取装饰器(支持 @decorator 和 @module.decorator)
            decorators = [...]
            # 提取代码体(按行号切分原始源码)
            body_lines = code.split("\n")[node.lineno - 1:node.end_lineno]

            symbols.append(CodeSymbol(
                name=node.name,
                symbol_type="function",
                ...
            ))

        elif isinstance(node, ast.ClassDef):
            # 类解析 + 递归提取类方法(过滤 self/cls 参数)
            ...

设计取舍:为什么没用 tree-sitter?

tree-sitter 能提供更精确的多语言 AST,但引入它意味着:

  1. 需要为每种语言下载 grammar 文件(增加部署复杂度)
  2. Python 绑定对 Windows 环境支持不够稳定
  3. 当前场景下,正则提取函数签名已经够用

这是一个典型的"够用就好"的工程决策。如果后续需要解析 Rust、C++ 等更复杂的语言,再升级到 tree-sitter 是合理路径。

2.3 CodeChunker:三种分块策略与降级机制

代码分块是整个导入链路中最影响检索质量的环节。系统支持三种策略:

策略一:函数级分块(默认)

一个函数/方法 → 一个代码块
块内容 = 函数签名 + docstring + 代码体

这是最精细的粒度,适合"这个函数是做什么的?"类的查询。每个块保留完整的函数签名上下文,而不是裸代码片段。

策略二:类级分块

一个类 → 一个代码块(包含所有方法)
独立函数(不属于任何类)→ 单独成块

适合"这个类有哪些方法?"类的查询。缺点是类很大时块会超长,因此有截断保护。

策略三:文件级分块

整个文件 → 一个代码块

最粗的粒度,作为前两种策略解析失败时的降级方案

降级链路

函数级分块 → AST 解析失败 → 降级为文件级
类级分块   → AST 解析失败 → 降级为文件级
文件级分块 → 永远成功(直接截断)

这个降级设计保证了任何代码文件都能被成功导入,不会因为某个文件语法异常而中断整个仓库的处理。

超长保护

所有分块都有 max_chunk_size=2000 字符的截断阈值。超过时追加 \n... (truncated) 标记,让 LLM 在生成回答时知晓内容被截断,避免幻觉。

2.4 双写存储:Qdrant 向量 + Neo4j 图谱

代码分块后,数据被写入两个存储:

Qdrant kb_code 集合(向量索引)

每个代码块作为一条 Point 存入,payload 结构:

{
    "content": "def get_user(self, user_id): ...",
    "title": "UserService.get_user",          # 符号名
    "parent_title": "app/services/user.py",   # 文件路径
    "source_type": "code_repo",               # 来源类型(隔离关键字段)
    "source": "gitee://owner/repo#master#app/services/user.py",  # 私有协议
    "language": "python",
    "file_path": "app/services/user.py",
    "symbol_name": "get_user",
    "symbol_type": "function",                # function/class/method
    "start_line": 42,
    "end_line": 58,
    "item_name": "owner/repo",                # 仓库标识(用于按仓库过滤)
}

向量使用 dense + sparse 混合向量,与本地文档、Gitee 文档共享同一套 Embedding 模型,但存储在独立的 kb_code 集合——语义空间隔离是避免跨来源污染的关键。

Neo4j 代码图谱(符号关系)

AST 解析出的符号被构建为图数据库中的节点和关系:

节点类型:
  CodeFile    — 代码文件(path, repo, branch)
  CodeClass   — 类(name, docstring, language)
  CodeFunction — 函数/方法(qualified_name, parameters, docstring)

关系类型:
  CONTAINS  — 文件包含类/函数
  INHERITS  — 类继承(如 Dog → Animal)
  CALLS     — 函数调用(通过函数体文本匹配)
  IMPORTS   — 模块导入

其中 CALLS 关系的提取采用了简单但有效的方案——在函数体中搜索其他函数名:

def _extract_calls(self, symbols):
    all_func_names = {s.name for s in symbols if s.symbol_type in ("function", "method")}
    calls = []
    for symbol in symbols:
        if not symbol.body:
            continue
        for func_name in all_func_names:
            if func_name == symbol.name:
                continue
            # 匹配 func_name( 的调用模式
            pattern = re.compile(rf'\b{re.escape(func_name)}\s*(')
            if pattern.search(symbol.body):
                calls.append((symbol.name, func_name))
    return calls

这种方式无法捕捉跨文件的调用关系,但在同一文件内的调用图已经足够支撑跨库检索的桥接查询。


三、检索链路:普通路径与跨库路径

3.1 普通路径:向量检索 + RRF 融合

当用户发起查询时,node_search_embedding 节点会同时对 kb_docskb_gitee_dockb_code 三个集合发起向量检索。代码仓库的检索结果天然携带 source_type: "code_repo" 和 source: "gitee://..." 字段。

这些结果与其他来源的结果一起进入 RRF(Reciprocal Rank Fusion)  融合:

RRF_score = weight × (1 / (k + rank)),k=60

RRF 的输出会透传 source_typesourcefile_path 等关键字段,确保下游节点能感知每条结果的来源。

3.2 跨库路径:Neo4j 桥接与 LLM 回退

跨库检索是系统中最有意思的链路。它解决的是一个真实问题:用户问的是业务逻辑,但答案在代码里

例如用户问"登录流程是怎么实现的?"——设计文档描述了流程,但具体的实现在 AuthService.login() 函数里。跨库检索的目标是:从文档找到代码

两阶段检索

第一阶段:文档检索
    在 kb_docs + kb_gitee_doc 中检索与查询相关的设计文档

第二阶段:Neo4j 关系桥接
    通过文档节点的 chunk_id → 查找 IMPLEMENTS/MENTIONS 关系边 → 取回关联的代码块

回退通道:LLM 符号抽取

如果 Neo4j 中没有关系边(首次导入时关系尚未建立),系统会回退到 LLM 抽取:

文档内容 → LLM 抽取代码符号(函数名/类名/模块名)→ 向量检索 kb_code 集合
def _llm_extract_symbols(doc_content: str) -> Dict[str, Any]:
    """用 LLM 从文档内容中抽取代码符号"""
    prompt = load_prompt("extract_code_symbols", context=doc_content[:3000])
    llm = get_llm_client(json_mode=True)
    response = llm.invoke([HumanMessage(content=prompt)])
    # 返回: {"functions": [...], "classes": [...], "modules": [...]}

关系回写:渐进式积累

跨库检索完成后,系统会将本次的文档-代码对应关系回写到 Neo4j:

def _write_back_relationships(doc_chunks, code_chunks):
    """回写 IMPLEMENTS 关系边,逐步积累跨库关系"""
    for doc in doc_chunks[:3]:        # 最多回写 3 个文档节点
        for code in code_chunks[:3]:  # 最多回写 3 个代码节点
            create_cross_kb_relationship(
                source_label="GiteeDocChunk",  # 或 DocChunk
                source_id=doc["chunk_id"],
                target_label="CodeFunction",
                target_id=code["chunk_id"],
                rel_type="IMPLEMENTS"
            )

这意味着跨库检索会越用越准——第一次依赖 LLM 回退,第二次开始就能直接通过关系边精准取回。这是一种典型的"写时积累、读时加速"的工程模式。

3.3 路由编排:LangGraph 的条件边

查询链路的路由由 main_graph.py 中的条件边控制:

def route_after_confirm(state):
    search_type = state.get("search_type", "all")
    if search_type == "cross_kb":
        return "cross_kb"
    return "normal"

# cross_kb 路径:跨库检索 → RRF → Rerank → 输出
# normal 路径:多路并行(向量+HyDE+Web+图谱)→ RRF → Rerank → 输出

两条路径最终都汇入 RRF → Rerank → Answer Output,保证了排序逻辑的统一。


四、来源溯源:从 gitee:// 协议到前端卡片

4.1 gitee:// 私有协议

代码仓库在 Qdrant 中的 source 字段使用了一个自定义协议:

gitee://owner/repo#branch#file_path
gitee-doc://owner/repo#branch#file_path

选择自定义协议而非直接存 HTTPS URL 的原因:

  1. 信息完整性:协议体中编码了 owner、repo、branch、file_path 四个维度
  2. 解析灵活性:下游可以根据需要构造不同的 URL(blob、blame、raw 等)
  3. 与来源类型解耦source_type 字段标记类别,source 字段存储寻址信息

Rerank 节点中的 _parse_gitee_source() 负责将协议解析为可访问的 HTTPS URL:

def _parse_gitee_source(raw_source: str) -> tuple:
    """
    解析 gitee:// 协议
    返回: (https_url, repo_slug, branch, file_path)
    """
    rest = raw_source.split("://", 1)[1]
    parts = rest.split("#", 2)
    repo_slug = parts[0]
    branch = parts[1] if len(parts) > 1 else "master"
    file_path = parts[2] if len(parts) > 2 else ""
    gitee_url = f"https://gitee.com/{repo_slug}/blob/{branch}/{file_path}"
    return (gitee_url, repo_slug, branch, file_path)

4.2 Rerank 来源加分

在 Reranker 精排阶段,系统对自有数据源(本地文档、Gitee 文档、代码仓库)给予统一加分:

OWN_SOURCE_BOOST = 0.08

if item.get('source') in ('local', 'gitee_doc', 'code_repo'):
    base_score += OWN_SOURCE_BOOST

这个设计的意图是:当 Reranker 给出的基础分数相近时,优先展示自有数据而非网页搜索结果。0.08 的取值来自经验——足够在边界情况下改变排序,但不会让低质量的自有内容强行排在高质量网页前面。

4.3 前端引用卡片

最终,每条引用来源在前端被渲染为一张卡片,根据 source 字段展示不同的标签颜色和图标:

source 值标签文字图标标签颜色链接目标
local本地📄蓝色本地 Markdown 文件
gitee_docGitee文档📗绿色Gitee 文件页
code_repo代码库💻黄色Gitee 代码文件
web网页🌐灰色原始网页 URL

引用来源链路的完整性验证

在开发过程中遇到过一个典型的 Bug:Rerank 节点曾经将所有 Qdrant 结果硬编码为 source: "local",导致代码仓库的引用卡片显示为"本地 📄",链接指向一个不存在的本地路径。

修复方案是在 RRF 节点透传 source_type,在 Rerank 节点根据 source_type 分支处理,确保代码仓库的引用始终显示为"代码库 💻"并链接到 Gitee。


五、工程反思

5.1 代码分块的粒度困境

函数级分块是最精细的,但也带来一个问题:一个函数可能只有 10 行代码,向量表征信息量不足。在实践中,短函数的检索召回率明显低于长函数。

可能的优化方向:

  • 将函数所属类的类名、docstring 作为上下文拼接到块内容中
  • 对短函数(< 200字符)采用"函数 + 类头"的混合块

5.2 跨库关系的冷启动

跨库检索依赖 Neo4j 中的 IMPLEMENTS/MENTIONS 关系边,但首次导入时这些边是不存在的。关系回写机制解决了部分问题,但初始阶段仍然需要依赖 LLM 回退通道。

这是一个典型的冷启动问题,可能的缓解方案:

  • 导入阶段用 LLM 预建一批文档-代码关系
  • 在文档内容中用正则匹配代码符号名,自动建立初始关系

5.3 AST 解析的覆盖率

当前 AST 解析对 Python 支持最完整(真 AST),其他语言使用正则,覆盖率约 70-80%。对于箭头函数嵌套、匿名函数、装饰器链等复杂语法,正则容易漏提取。

升级路径很清晰:引入 tree-sitter,为每种语言加载 grammar,实现统一的多语言 AST 解析。但这需要在部署复杂度和解析精度之间做权衡。


六、总结

代码仓库的导入与检索是一个涉及多语言解析、分块策略、双写存储、多路检索、跨库桥接、来源溯源的完整工程链路。它不是文档 RAG 的简单扩展,而是需要针对代码的结构化特性做专门设计。

核心设计原则可以归纳为三点:

  1. 语义隔离:代码、文档、网页分别存储在独立的 Qdrant 集合,避免向量空间污染
  2. 渐进增强:跨库关系通过查询回写逐步积累,系统越用越准
  3. 来源透明:从 gitee:// 协议到前端卡片,每条引用都可溯源到原始代码位置

这些原则保证了系统在面对"这个登录接口是怎么实现的?"这类问题时,能精准地从代码仓库中找到 AuthService.login() 函数,并在引用卡片中显示为"代码库 💻",点击即可跳转到 Gitee 上的源码页面。


作者正在寻找 AI 工程方向的机会,欢迎交流。