33-模块五-AI系统架构设计 第33讲-代码库向量化索引 - 将整个仓库变成 AI 可检索的知识库

3 阅读21分钟

模块五-AI系统架构设计 | 第33讲:代码库向量化索引 - 将整个仓库变成 AI 可检索的知识库

本讲目标:解释「让 AI 理解整个仓库」为何必须工程化拆解为索引问题;对比文件级、函数级、类级、AST 语义分块策略;设计元数据增强(路径、符号、docstring、import、依赖线索);实现基于 git diff 的增量索引;交付 CodeSentinel CodebaseIndexer 编排:克隆/更新、分块、元数据抽取、嵌入、Chroma 持久化与查询 API。读完你应能把仓库从「单文件上下文」升级为「可检索知识库」,并清楚每种 chunk 策略的代价与适用边界。

先修提示:建议先完成第32讲的向量库基础;本讲 focus 在「如何切分与如何增量」,不重复讨论 HNSW/IVF 细节。

练习建议:找一份你熟悉的真实服务仓库,随机选 10 个历史 PR,把「当时审核需要的外部上下文」列成清单,再对照本讲的 chunk 元数据字段,看看是否都能被索引表达。若表达不了,你的缺口通常不在模型,而在数据模式。把清单当作你下一版索引 schema 的需求输入,并在评审会上逐条对齐优先级与验收口径。


开场:只看当前文件,模型永远在「盲人摸象」

真实审核问题往往是跨文件的:你在 auth/callback.py 看到一处改动,风险却可能来自 settings.py 的默认配置、clients/oauth.py 的错误重试、或 tests/test_oauth.py 缺失的覆盖。若 LLM 只能看到 diff 所在文件,它只能给出「局部合理、全局可能错误」的建议。向量索引的意义,是把仓库转写为 可检索的语义索引:在有限上下文窗口内,尽可能把「可能相关的证据」捞出来,再交给 Agent 或规则引擎组织。

很多团队会把「理解整个仓库」误解为「把仓库都塞进上下文」。这在工程上不可行,在产品上也不必要:审核关注的是与变更相关的子图与历史证据,而不是每一行代码的复述。正确的工程目标,是建立一个 可控规模的证据检索系统,让系统在毫秒到秒级内返回少量高价值片段,并能够解释「为什么这些片段被召回」。这正是本讲要把仓库索引化的原因。

但索引不是把仓库按行切碎那么简单。chunk 太大,检索粒度粗,容易把无关噪声混进来;chunk 太小,符号上下文丢失,embedding 语义漂移。元数据决定你能否按语言、模块、路径过滤,避免把 Go 服务的经验套到 Python 脚本上。增量索引决定你能否在大型单体仓库上保持更新成本可控:全量重建适合小规模;对百万行代码,必须用 git 变更驱动增量 upsert 与删除。

CodeSentinel 的 CodebaseIndexer 是本讲贯穿实现:它把「Git 工作区状态 → AST 分块 → 元数据 → 向量库」串成可测试流水线,并提供 search_codebase 查询接口。你会看到 TypeScript 在纯 Python 环境下的务实处理:教学实现用正则提取函数/类作为近似;生产可替换为 tree-sitter。架构上关键是 可替换的 Chunker 接口,而不是一次性写死解析器。

把问题放回业务场景:当 PR 引入一条看似无害的重构,审核系统最需要回答的往往是「有没有改变安全边界」「有没有破坏隐含契约」「有没有遗漏同类修复」。这些问题都依赖 跨文件证据,而不是单文件语法正确。向量索引提供的是「相关候选集合」,它必须与静态分析、测试差异、依赖变更信息并列存在,而不是替代它们。换句话说:索引解决的是注意力分配问题,不是判断真伪问题。理解这一点,你就不会在检索召回不足时盲目加 Agent 步数,而是回到 chunk 与元数据是否真的表达了仓库结构。

本讲还会强调 工程可测试性CodeChunk 应该是可序列化、可 diff 的结构体;chunk_id 必须稳定;MetadataExtractor 的输出应能被单元测试覆盖。很多团队在 POC 阶段把文本拼接写在索引脚本里,后期要加字段时发现无法回放历史索引,只能全量重建。CodeSentinel 建议把「文档文本(给 embedding)」与「结构化元数据(给过滤/重排)」分开演进:前者可以迭代摘要模板,后者必须保持向后兼容或做显式迁移版本。


全局视角:索引流水线(Mermaid)

flowchart LR
  subgraph Git[Git 层]
    CL[clone / fetch]
    DF[git diff 变更集]
  end

  subgraph Parse[解析层]
    CH[CodeChunker\nPython AST / TS 近似]
    MD[MetadataExtractor]
  end

  subgraph Index[索引层]
    EM[Embedding\n可插拔]
    DB[(Chroma)]
  end

  subgraph API[服务层]
    Q[Search API]
  end

  CL --> CH
  DF --> CH
  CH --> MD --> EM --> DB
  Q --> EM
  DB --> Q

分块策略对比:粒度决定检索性格(Mermaid)

在进入对比图之前,先统一一个概念:chunk 是检索与计费的共同单位。chunk 越细,更新越局部、嵌入成本越低,但查询时需要更多后处理拼装上下文;chunk 越粗,上下文更完整,但更新粒度变粗,且噪声更容易污染向量。CodeSentinel 没有「银弹粒度」,只有「面向任务的折中」。实践中常见路线是:默认函数级 + 对超大类/超大文件启用二级切分 + 对配置/文档使用文件级或段落级。这条路线能在大多数服务代码仓库上工作良好。

flowchart TB
  F[文件级 chunk] -->|优点: 实现简单| FA[缺点: 噪声大\n窗口难装下]
  FN[函数级 chunk] -->|优点: 语义聚焦| FNA[缺点: 丢跨函数上下文]
  CL[类级 chunk] -->|优点: 方法聚合| CLA[缺点: 大类会膨胀]
  SE[AST 语义块] -->|优点: 结构保真| SEA[缺点: 解析成本高]

  CS[CodeSentinel 默认] --> FN
  CS --> SE

核心原理:分块、元数据与增量

1. 文件级分块:简单但有天花板

把整个文件当作一个文档嵌入,优点是实现极简;缺点是:长文件会稀释语义,短文件又可能只有 import。对 CodeSentinel,文件级适合作为 辅助索引(例如「快速定位文件摘要」),但不适合作为唯一 chunk 源。

2. 函数级分块:性价比最高的默认

以函数/方法为边界,能把「行为语义」聚拢在一起,检索时更容易命中「类似实现」。注意要保留 函数签名与装饰器 这类强语义信息,否则 embedding 会丢关键线索。

3. 类级分块:适合 OO 密集域

当方法之间强耦合、单独函数无法理解不变量时,类级更合理。风险是:大类会超过 embedding 输入上限,需要二级拆分(例如按方法再切)。

4. AST 语义块:结构优先

基于 AST 把逻辑块(循环、分支、try/except)切片,更贴近程序结构,适合安全审计场景。但解析与序列化成本高,需要缓存与容错(解析失败回退到函数级)。

5. 元数据增强:让过滤成为可能

建议至少抽取:file_pathlanguagesymbol_namekind(function/class/module)、docstring 摘要、imports 列表、start_line/end_line。可选:layer(domain)、git_sha。元数据不仅服务过滤,也服务 重排序特征(下一讲)。

6. 增量索引:用 git diff 驱动变更集合

流程:git diff --name-only base..head 得变更文件 → 对删除文件删除 chunk → 对新增/修改文件重新解析并 upsert。要注意 重命名大文件二进制 过滤。CodeSentinel 应用 content_hash 作为稳定 chunk id 的关键部分,避免重复写入。

7. Python AST chunker 要点

使用 ast.parse,遍历 FunctionDefAsyncFunctionDefClassDef。对类可选择在类节点内再抽取方法,或把整个类作为一个 chunk(可配置)。要记录 lineno/end_lineno(Python 3.8+)以便映射 PR 行号。

8. TypeScript 的务实策略

纯 Python 环境可用正则提取 export functionfunctionclass 等结构作为教学近似;并明确标注误报可能。生产推荐 tree-sitter 或调用 typescript compiler API 作为独立微服务。

9. 查询 API 设计

输入:query_textrepopath_prefixlanguagetop_k。输出:带 scoremetadatasnippet 的列表。服务层应对接统一鉴权与审计日志,避免向量检索成为数据外泄通道。

10. 失败与降级

解析失败文件应记录 metrics 并跳过,而不是阻塞全量索引。对超大文件应截断或只索引符号表级别摘要。

11. chunk 文本模板:为什么要拼接 header

你会注意到 MetadataExtractor.enrich_document 把 imports、docstring、路径信息拼进送入 embedding 的文本。原因是:纯代码片段有时缺少「它属于哪个模块」的语义锚点,向量模型可能把相似语法但不同域的代码聚在一起。适度拼接结构化字段,有助于 检索对齐用户自然语言问题(例如问题提到 OAuth,而代码里没有 OAuth 字样但路径在 auth/)。代价是:模板变更会导致向量空间漂移,因此模板本身要版本化,并在重建索引时全量切换。

12. 增量删除:教学实现 vs 生产一致性

教学版 delete_by_paths 通过 get 全量扫描 ids 来删除路径,这在数据量大时不可接受。生产应维护 二级索引path -> chunk_ids 或把 path 作为 Chroma 可查询字段并结合更高效的删除 API;或在对象存储侧维护 manifest。核心原则是:增量更新必须可证明一致,否则会出现「文件删了但向量还在」的幽灵命中,审核系统会引用不存在代码,信任瞬间崩塌。

13. mono repo 的分层索引策略

大型 mono repo 不建议只有一个巨型 collection。可按 服务边界语言团队域 分 collection,查询时并行检索再融合(RRF 或加权)。这能降低过滤压力,也能隔离 noisy neighbor:前端大量模板代码不应淹没后端安全敏感模块的召回。

14. 与 PR diff 的对齐:行号映射

审核评论需要定位到行号。chunk 记录 start_line/end_line 只是第一步;当文件在分支间发生偏移,还需要把 稳定锚点(函数符号)与 diff hunks 对齐。常见做法是:以符号为主键,在生成评论时回到当前 head_sha 重新解析定位行号,而不是直接信任旧索引行号。


代码实战:AST 分块、元数据抽取、CodebaseIndexer 与搜索 API

依赖

chromadb>=0.5.0

完整可运行示例(codebase_indexer.py

"""
CodeSentinel CodebaseIndexer:克隆/更新(可选)+ AST 分块 + 元数据 + Chroma 索引 + 搜索 API
运行: python codebase_indexer.py
环境:
  - 默认使用本仓库当前目录作为 REPO_ROOT(无需网络)
  - 设置 USE_CHROMA_DEFAULT_EMBEDDINGS=0 可在离线环境使用哈希嵌入(仅演示)
"""
from __future__ import annotations

import ast
import hashlib
import os
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple

import chromadb
from chromadb.api.models.Collection import Collection
from chromadb.api.types import Documents, EmbeddingFunction, Embeddings


class HashingEmbedding(EmbeddingFunction):
    def __init__(self, dim: int = 128) -> None:
        self.dim = dim

    def __call__(self, input: Documents) -> Embeddings:
        out: Embeddings = []
        for text in list(input):
            h = hashlib.sha256(text.encode("utf-8")).digest()
            vec: List[float] = []
            for i in range(self.dim):
                b = h[i % len(h)]
                vec.append((b / 255.0) * 2 - 1.0)
            out.append(vec)
        return out


@dataclass
class CodeChunk:
    chunk_id: str
    repo: str
    path: str
    language: str
    symbol: str
    kind: str
    text: str
    start_line: int
    end_line: int
    docstring: str = ""
    imports: List[str] = field(default_factory=list)


def stable_chunk_id(repo: str, path: str, symbol: str, content_hash: str) -> str:
    raw = f"{repo}|{path}|{symbol}|{content_hash}"
    return hashlib.sha1(raw.encode("utf-8")).hexdigest()


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8", errors="ignore")


def extract_python_imports(tree: ast.AST) -> List[str]:
    imports: List[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for n in node.names:
                imports.append(n.name.split(".")[0])
        elif isinstance(node, ast.ImportFrom):
            if node.module:
                imports.append(node.module.split(".")[0])
    # 去重保序
    seen = set()
    out: List[str] = []
    for m in imports:
        if m not in seen:
            seen.add(m)
            out.append(m)
    return out[:50]


def py_get_docstring(node: ast.AST) -> str:
    doc = ast.get_docstring(node)
    return (doc or "").strip().replace("\n", " ")[:400]


class PythonChunker:
    def chunk_file(self, repo: str, rel_path: str, source: str) -> List[CodeChunk]:
        tree = ast.parse(source)
        imports = extract_python_imports(tree)
        chunks: List[CodeChunk] = []

        for node in tree.body:
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                seg = ast.get_source_segment(source, node) or ""
                doc = py_get_docstring(node)
                h = hashlib.sha1(seg.encode("utf-8")).hexdigest()
                sym = node.name
                cid = stable_chunk_id(repo, rel_path, sym, h)
                chunks.append(
                    CodeChunk(
                        chunk_id=cid,
                        repo=repo,
                        path=rel_path,
                        language="python",
                        symbol=sym,
                        kind="function",
                        text=f"# {rel_path}:{sym}\n{seg}",
                        start_line=int(getattr(node, "lineno", 1) or 1),
                        end_line=int(getattr(node, "end_lineno", node.lineno) or node.lineno),
                        docstring=doc,
                        imports=imports,
                    )
                )
            elif isinstance(node, ast.ClassDef):
                seg = ast.get_source_segment(source, node) or ""
                doc = py_get_docstring(node)
                h = hashlib.sha1(seg.encode("utf-8")).hexdigest()
                sym = node.name
                cid = stable_chunk_id(repo, rel_path, sym, h)
                chunks.append(
                    CodeChunk(
                        chunk_id=cid,
                        repo=repo,
                        path=rel_path,
                        language="python",
                        symbol=sym,
                        kind="class",
                        text=f"# {rel_path}:{sym}\n{seg}",
                        start_line=int(getattr(node, "lineno", 1) or 1),
                        end_line=int(getattr(node, "end_lineno", node.lineno) or node.lineno),
                        docstring=doc,
                        imports=imports,
                    )
                )

        if not chunks:
            h = hashlib.sha1(source.encode("utf-8")).hexdigest()
            cid = stable_chunk_id(repo, rel_path, "__module__", h)
            chunks.append(
                CodeChunk(
                    chunk_id=cid,
                    repo=repo,
                    path=rel_path,
                    language="python",
                    symbol="__module__",
                    kind="module",
                    text=f"# {rel_path}:module\n{source[:8000]}",
                    start_line=1,
                    end_line=len(source.splitlines()),
                    docstring="",
                    imports=imports,
                )
            )
        return chunks


class TypeScriptChunker:
    """
    教学近似:用正则提取顶层 function/class。
    生产请替换 tree-sitter。
    """

    _fn = re.compile(
        r"(export\s+)?(async\s+)?function\s+(\w+)\s*\([^)]*\)\s*(:\s*[\w<>\[\]\|\s]+)?\s*\{",
        re.MULTILINE,
    )
    _cls = re.compile(r"(export\s+)?class\s+(\w+)\b", re.MULTILINE)

    def chunk_file(self, repo: str, rel_path: str, source: str) -> List[CodeChunk]:
        chunks: List[CodeChunk] = []
        lines = source.splitlines()

        for m in self._fn.finditer(source):
            sym = m.group(3)
            start = source[: m.start()].count("\n") + 1
            brace = source[m.start() :].find("{")
            if brace < 0:
                continue
            i = m.start() + brace
            end_idx = self._match_brace(source, i)
            if end_idx < 0:
                continue
            seg = source[m.start() : end_idx + 1]
            end = source[: end_idx].count("\n") + 1
            h = hashlib.sha1(seg.encode("utf-8")).hexdigest()
            cid = stable_chunk_id(repo, rel_path, sym, h)
            chunks.append(
                CodeChunk(
                    chunk_id=cid,
                    repo=repo,
                    path=rel_path,
                    language="typescript",
                    symbol=sym,
                    kind="function",
                    text=f"// {rel_path}:{sym}\n{seg}",
                    start_line=start,
                    end_line=end,
                    docstring="",
                    imports=self._imports(source),
                )
            )

        for m in self._cls.finditer(source):
            sym = m.group(2)
            start = source[: m.start()].count("\n") + 1
            brace = source[m.start() :].find("{")
            if brace < 0:
                continue
            i = m.start() + brace
            end_idx = self._match_brace(source, i)
            if end_idx < 0:
                continue
            # 从 class 关键字起点截取完整类块
            seg = source[m.start() : end_idx + 1]
            end = source[: end_idx].count("\n") + 1
            h = hashlib.sha1(seg.encode("utf-8")).hexdigest()
            cid = stable_chunk_id(repo, rel_path, sym, h)
            chunks.append(
                CodeChunk(
                    chunk_id=cid,
                    repo=repo,
                    path=rel_path,
                    language="typescript",
                    symbol=sym,
                    kind="class",
                    text=f"// {rel_path}:{sym}\n{seg}",
                    start_line=start,
                    end_line=end,
                    docstring="",
                    imports=self._imports(source),
                )
            )

        if not chunks:
            h = hashlib.sha1(source.encode("utf-8")).hexdigest()
            cid = stable_chunk_id(repo, rel_path, "__module__", h)
            chunks.append(
                CodeChunk(
                    chunk_id=cid,
                    repo=repo,
                    path=rel_path,
                    language="typescript",
                    symbol="__module__",
                    kind="module",
                    text=f"// {rel_path}:module\n" + "\n".join(lines[:400]),
                    start_line=1,
                    end_line=min(len(lines), 400),
                    docstring="",
                    imports=self._imports(source),
                )
            )
        return chunks

    def _imports(self, source: str) -> List[str]:
        imps: List[str] = []
        for line in source.splitlines():
            s = line.strip()
            if s.startswith("import "):
                imps.append(s.split()[1].split("/")[0].replace("\"", "").replace("'", ""))
            elif s.startswith("export ") and " from " in s:
                imps.append(s.split(" from ")[-1].strip().strip(";").strip("\"'"))
        seen = set()
        out: List[str] = []
        for m in imps:
            if m and m not in seen:
                seen.add(m)
                out.append(m)
        return out[:50]

    def _match_brace(self, s: str, open_idx: int) -> int:
        if open_idx < 0 or open_idx >= len(s) or s[open_idx] != "{":
            return -1
        depth = 0
        in_str = False
        quote = ""
        i = open_idx
        while i < len(s):
            ch = s[i]
            if in_str:
                if ch == quote and s[i - 1] != "\\":
                    in_str = False
                i += 1
                continue
            if ch in ("\"", "'", "`"):
                in_str = True
                quote = ch
                i += 1
                continue
            if ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    return i
            i += 1
        return -1


class MetadataExtractor:
    def enrich_document(self, c: CodeChunk) -> str:
        imps = ", ".join(c.imports[:20])
        header = (
            f"[repo={c.repo} path={c.path} lang={c.language} kind={c.kind} symbol={c.symbol} "
            f"lines={c.start_line}-{c.end_line}]\n"
            f"imports: {imps}\n"
            f"doc: {c.docstring}\n"
        )
        return header + "\n" + c.text


class CodebaseIndexer:
    def __init__(self, persist_dir: str) -> None:
        os.makedirs(persist_dir, exist_ok=True)
        self.client = chromadb.PersistentClient(path=persist_dir)
        use_real = os.getenv("USE_CHROMA_DEFAULT_EMBEDDINGS", "1") == "1"
        if use_real:
            self.collection = self.client.get_or_create_collection(
                name="codesentinel_codebase",
                metadata={"hnsw:space": "cosine"},
            )
        else:
            self.collection = self.client.get_or_create_collection(
                name="codesentinel_codebase",
                embedding_function=HashingEmbedding(128),
                metadata={"hnsw:space": "cosine"},
            )
        self.py = PythonChunker()
        self.ts = TypeScriptChunker()
        self.meta = MetadataExtractor()

    def index_repo_path(self, repo: str, root: Path, patterns: Sequence[str] = ("**/*.py", "**/*.ts")) -> int:
        chunks: List[CodeChunk] = []
        for pat in patterns:
            for fp in root.glob(pat):
                if fp.is_dir():
                    continue
                rel = str(fp.as_posix().replace(root.as_posix(), "").lstrip("/"))
                if any(p in rel for p in ("/.git/", "/node_modules/", "/dist/", "/__pycache__/")):
                    continue
                src = read_text(fp)
                if fp.suffix == ".py":
                    try:
                        chunks.extend(self.py.chunk_file(repo, rel, src))
                    except SyntaxError:
                        continue
                elif fp.suffix == ".ts":
                    chunks.extend(self.ts.chunk_file(repo, rel, src))
        self.upsert_chunks(chunks)
        return len(chunks)

    def upsert_chunks(self, chunks: Sequence[CodeChunk]) -> None:
        if not chunks:
            return
        ids = [c.chunk_id for c in chunks]
        documents = [self.meta.enrich_document(c) for c in chunks]
        metadatas = [
            {
                "repo": c.repo,
                "path": c.path,
                "language": c.language,
                "symbol": c.symbol,
                "kind": c.kind,
                "start_line": c.start_line,
                "end_line": c.end_line,
            }
            for c in chunks
        ]
        self.collection.upsert(ids=ids, documents=documents, metadatas=metadatas)  # type: ignore[arg-type]

    def delete_by_paths(self, repo: str, paths: Sequence[str]) -> None:
        """简化:按路径过滤删除(教学)。生产应用 where + 批量删除或维护 path 二级索引。"""
        if not paths:
            return
        res = self.collection.get(where={"repo": repo}, include=["metadatas", "ids"])
        ids = res.get("ids") or []
        metas = res.get("metadatas") or []
        to_delete = []
        path_set = set(paths)
        for cid, md in zip(ids, metas):
            if md and md.get("path") in path_set:
                to_delete.append(cid)
        if to_delete:
            self.collection.delete(ids=to_delete)

    def incremental_index_git(self, repo: str, root: Path, base_ref: str, head_ref: str = "HEAD") -> Tuple[int, List[str]]:
        """
        使用 git diff 找出变更文件,再对变更路径重索引。
        需要 root 是 git 仓库。
        """
        cmd = ["git", "-C", str(root), "diff", "--name-only", f"{base_ref}...{head_ref}"]
        out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT)
        changed = [ln.strip() for ln in out.splitlines() if ln.strip()]
        # 删除路径(可能被删除的文件)
        deleted = []
        for p in changed:
            if not (root / p).exists():
                deleted.append(p)
        self.delete_by_paths(repo, deleted)

        chunks: List[CodeChunk] = []
        for p in changed:
            fp = root / p
            if not fp.exists() or fp.is_dir():
                continue
            if fp.suffix not in (".py", ".ts"):
                continue
            src = read_text(fp)
            if fp.suffix == ".py":
                try:
                    chunks.extend(self.py.chunk_file(repo, p.replace("\\", "/"), src))
                except SyntaxError:
                    continue
            else:
                chunks.extend(self.ts.chunk_file(repo, p.replace("\\", "/"), src))
        self.upsert_chunks(chunks)
        return len(chunks), changed


def search_codebase(
    coll: Collection,
    query: str,
    repo: Optional[str] = None,
    language: Optional[str] = None,
    path_prefix: Optional[str] = None,
    k: int = 8,
) -> List[Dict]:
    where_parts = []
    if repo:
        where_parts.append({"repo": repo})
    if language:
        where_parts.append({"language": language})
    if path_prefix:
        where_parts.append({"path": {"$gte": path_prefix}})  # 教学:字典序前缀近似
    where = None
    if len(where_parts) == 1:
        where = where_parts[0]
    elif len(where_parts) > 1:
        where = {"$and": where_parts}

    res = coll.query(query_texts=[query], n_results=k, where=where)
    rows: List[Dict] = []
    for i in range(len(res["ids"][0])):
        rows.append(
            {
                "id": res["ids"][0][i],
                "distance": res["distances"][0][i] if res.get("distances") else None,
                "document": res["documents"][0][i],
                "metadata": res["metadatas"][0][i],
            }
        )
    return rows


def main() -> None:
    repo = os.getenv("CS_REPO_NAME", "local/codesentinel")
    root = Path(os.getenv("REPO_ROOT", os.getcwd()))
    persist = os.getenv("CHROMA_DIR", ".chroma_codesentinel_codebase")

    indexer = CodebaseIndexer(persist)

    mode = os.getenv("INDEX_MODE", "full")
    if mode == "incremental":
        base = os.getenv("GIT_BASE", "HEAD~1")
        n, changed = indexer.incremental_index_git(repo, root, base_ref=base, head_ref="HEAD")
        print(f"incremental indexed chunks={n}, files={len(changed)}")
    else:
        n = indexer.index_repo_path(repo, root)
        print(f"full indexed chunks={n}")

    coll = indexer.collection
    print("\n=== search_codebase ===")
    for row in search_codebase(
        coll,
        "哪里在处理 OAuth state 校验?",
        repo=repo,
        language="python",
        k=5,
    ):
        print(row["metadata"], "distance=", row["distance"])


if __name__ == "__main__":
    main()

与 CodeSentinel 平台组件的映射

  • Git 克隆/更新:生产用凭据隔离与浅克隆;教学直接用 REPO_ROOT
  • Chunker:建议插件化注册 PythonChunkerTypeScriptChunkerGoChunker
  • Indexer Worker:Webhook 触发异步任务;本讲 main() 是同步演示。
  • Search API:FastAPI 包装 search_codebase,加鉴权与 rate limit。

运行示例:如何在本地验证

  1. 在仓库根目录执行 set INDEX_MODE=full(PowerShell 用 $env:INDEX_MODE="full"),再运行 python codebase_indexer.py(需先把脚本保存为独立文件)。
  2. 若 CI 无外网,设置 USE_CHROMA_DEFAULT_EMBEDDINGS=0 使用哈希嵌入,仅验证流水线正确性。
  3. 增量模式:set INDEX_MODE=incrementalset GIT_BASE=HEAD~1,确保当前目录是 git 仓库。

提示:正文中的 codebase_indexer.py 为完整单文件示例;你也可以把它放进 codesentinel/indexing/ 包并拆分模块,但请保持 chunk_id 规则不变,以免破坏增量幂等。

常见坑:把「路径」写进文本但不写进 metadata

一些同学习惯只在 embedding 文本里写 path=...,却不在 metadata 里存 path,导致无法高效过滤。请记住:embedding 文本服务模型理解,metadata 服务系统过滤。两者缺一不可。另一个常见坑是 Windows 路径分隔符:示例里对 rel_path 做了 / 归一,避免同文件产生两套 chunk。


生产环境实战

1. 解析失败与恶意源码

AST 解析可能因语法版本或半成品提交失败;要有 per-file 错误隔离。对恶意构造的超大嵌套字面量,需要 文件大小与节点数上限

2. path_prefix 过滤的正确做法

教学代码用字典序 $gte 近似前缀,生产应使用支持前缀匹配的 payload 索引或分 collection。不要依赖字典序侥幸。

3. 秘密与敏感文件

.envid_rsa、证书路径应 denylist;索引前扫描文件名与内容模式,必要时阻断入库并告警。

4. 多分支策略

默认索引 default branch 的 HEAD;对 PR 预览可索引 merge-base 与 head 的双快照,或只索引 diff 相关文件。

5. 观测与回放

记录每次索引的 git_sha、chunk_count、duration、fail_files,并保留解析产物 hash,便于复现「为什么当时检索不到」。

6. Worker 资源与并发

索引任务会消耗 CPU(解析)与网络/GPU(embedding)。应对 worker 设置并发上限,避免与在线查询争抢;并对大仓库使用分片任务(按目录队列化)。CodeSentinel 若在容器内运行,记得把 CHROMA_DIR 挂到持久卷,否则重启丢索引。

7. 权限模型:谁能检索谁的仓库

搜索 API 必须与租户权限对齐:同一向量库里混放多租户数据时,where 过滤不够,还需要在应用层验证 repo 访问令牌。否则会出现「猜 query 窃取他仓片段」的越权风险。更稳妥是物理隔离敏感仓库的 collection。

8. 备份与恢复:向量不是唯一资产

向量 collection 可以重建,但重建耗时与嵌入成本可能很高。生产应备份 原始 chunk manifest(id、hash、路径、版本)embedding 缓存,使得灾难恢复时可以先恢复 manifest,再并行重算缺失向量。否则你只能「全量重扫」,在事故窗口内审核能力会长时间降级。


本讲小结(Mermaid mindmap)

mindmap
  root((第33讲 代码库索引))
    分块
      文件级
      函数级默认
      类级
      AST语义
    元数据
      imports
      docstring
      行号范围
    增量
      git diff
      upsert删除
    CodebaseIndexer
      编排管线
      可替换Chunker
    查询API
      过滤与审计

思考题

  1. 函数级分块在什么情况下会把「跨函数不变量」切碎?你会如何补上下文?
  2. 增量索引时如何处理「文件重命名」导致的旧 chunk 残留?
  3. 如果把 import 图也索引进来,应作为独立 chunk 还是作为函数元数据?利弊是什么?

案例拆解:三类查询在索引层的不同需求

为了把抽象讲具体,下面用三个「CodeSentinel 常见查询」说明索引应如何配合。查询一:「这次改动是否重复了历史缺陷模式?」 这类问题强调跨时间检索,metadata 需要 commit_sha、时间、模块、缺陷标签(若已有);chunk 更适合函数级,并在文档模板里加入「历史修复摘要」字段(如果有工单系统)。查询二:「同类 API 在仓库里还有哪些调用点?」 这类问题强调符号与引用关系,仅靠向量往往不够,imports 元数据是低配方案,高配是调用图服务;索引层至少要能把 symbolpath 过滤做好。查询三:「变更是否违反架构分层规则?」 这类问题强调路径与模块边界,metadata 的 layerowner_team 比 embedding 更关键;索引应能把架构元数据稳定写进去,而不是指望模型从路径猜。

这三个案例共同的结论是:索引文本解决语义相似,metadata 解决约束与边界。CodeSentinel 的架构师要把两者都设计进数据模型,而不是把一切都塞进一段 prompt。否则你会看到系统「好像能搜到」,却无法稳定回答治理类问题。


代码走读:关键函数的设计意图(补充说明)

PythonChunker.chunk_file 选择在模块顶层遍历 tree.body,是为了优先拿到「对外可见」的定义,避免把嵌套函数过早切碎。若你的代码库大量使用嵌套函数承载核心逻辑,需要扩展策略:对「顶层很薄、逻辑全在内部函数」的文件,应降级为类级或增加「浅层嵌套展开」规则。TypeScriptChunker 的大括号匹配是典型教学实现,它刻意写得冗长,是为了提醒:括号匹配在真实 TS/JS 里还有模板字符串、正则字面量、注释等陷阱;因此生产必须换 tree-sitter 或官方 parser。

CodebaseIndexer.incremental_index_git 使用三方点 base...head 的 diff 名称列表,是常用 Git 语义;在 PR 场景下 base 常选 merge-base。教学代码不替你做所有 Git 边界情况,但你在落地时要处理:子模块、LFS、以及「仅权限变更」导致的假变更。search_codebase 的参数组合建议由上层 Agent 控制:Agent 先判断语言与模块,再调用检索,避免一开始就用超大 topK 污染上下文。


下一讲预告

第34讲:RAG 架构深度优化,将讨论混合检索(向量 + BM25)、重排序、查询改写与上下文拼装,专门针对代码审核场景的 AST/调用链增强检索。你可以把本讲产出的 CodeChunk 与 metadata 直接作为下一讲的输入特征:混合检索会用到文本,重排序会用到路径与符号,上下文拼装会用到行号范围。


延伸阅读:从「能搜」到「搜得对」

索引工程最容易被低估的部分不是解析,而是 评测与回归。建议你维护一个「检索用例库」:给定查询语句与期望必须命中的文件/符号列表,每次变更 chunk 规则都跑一遍命中排名。没有这套测试,你会陷入「调 embedding、调 topK、调 prompt」的无尽循环,却无法证明问题来自检索还是生成。CodeSentinel 作为治理平台,更应把检索命中率与误命中代价量化,而不是只展示炫酷的语义搜索 demo。

另一个现实问题是 多语言混合仓库:mono repo 里同时存在前端、后端、基础设施代码,查询词可能自然语言化而目标代码是另一种语言。解决办法包括:查询侧做「语言意图分类」、检索侧用 metadata 过滤默认语言、以及为架构文档单独建 collection。把「自然语言问题」与「代码符号」之间的鸿沟显式建模,比单纯增大 embedding 维度更有效。

再补充一个「落地检查清单」,帮助你把本讲代码从实验搬到生产:清单 A:数据——是否排除二进制与生成代码?是否处理编码与换行差异?清单 B:ID——chunk_id 是否幂等?重命名文件时旧向量是否清理?清单 C:性能——全量索引是否在可接受时间完成?增量是否能在 PR 时间窗内结束?清单 D:安全——是否扫描密钥模式?是否记录查询审计?清单 E:可观测——索引失败是否可告警?是否能按仓库维度看 chunk 数异常下跌?把清单当作发布门槛,而不是文档装饰,CodeSentinel 才不会沦为「本地好用、线上随缘」。

最后谈谈团队协作:索引规则变更往往牵动算法、平台、安全三方。建议建立 RFC + 影子索引:新规则并行构建一套 shadow collection,对同样查询对比命中差异,再决定是否切换。没有影子对比,规则升级很容易变成「感觉更好」但指标更差的玄学改动。

再把「仓库知识库」与「即时 PR 上下文」的关系说透:索引解决的是 背景知识,PR diff 解决的是 变更焦点。CodeSentinel 在生成评审时,理想状态是:diff 作为主轴,检索结果作为旁证;如果反过来把检索到的大段历史代码当主轴,评论会变得冗长且抓不住重点。为此,索引查询应支持 围绕变更文件的局部检索(path 前缀、同模块、同 owner),把全局语义搜索留给「探索式问题」而不是每条 PR 的默认路径。这个产品设计判断会显著影响你对 top_k、上下文拼装与评论长度的控制策略。

此外,索引层与「代码托管 API」之间要有清晰分工:托管平台提供权威文件内容与行号,向量库提供候选集合与相似性线索。不要把向量库当作源码真相来源;当 HEAD 已前进而索引尚未更新时,系统应显式提示「索引滞后」,并在可能情况下触发增量任务或回退到平台 API 读取文件。否则会出现引用过期片段的「幽灵评论」,对工程师体验伤害极大。