给 AI Agent 做一个"代码大脑":我用 Tree-sitter + ChromaDB + MCP 搭了个代码知识库
声明在先:这是一个半成品项目,很多功能还处于"能用但不好用"的阶段。本文主要分享我在开发过程中的架构设计思路和一些踩坑经验,不是什么成熟的解决方案。如果你也在做类似的事情,希望能给你一些参考。
GitHub 地址:github.com/mufans/ai-c...
起点:一个很实际的需求
用 AI 写代码的时候,你有没有遇到过这种情况:让 Cursor 或者 Claude 帮你改一个跨多文件的功能,它要么改了这里忘了那里,要么给你生成一段跟现有代码风格完全不搭的实现。
单仓库单文件的问题还只是皮毛。真正让人头疼的是跨仓库的场景。
比如你在做一个微服务架构的项目,服务 A 调了服务 B 的接口,服务 B 又依赖公共库 C。你让 AI Agent 帮你排查一个跨服务的 Bug,它根本无从下手------因为它看不到仓库 A、B、C 之间的关联。再比如你维护了好几个开源项目,想找一个之前在另一个项目里写过的工具函数,AI 也帮不了你,因为它不知道你的代码库里到底有些什么。
这还只是开发者自己的情况。如果你是一个团队的 Tech Lead,团队里有十几个仓库,新人入职想理解某个服务的调用链路,光翻 README 不够,得一个仓库一个仓库地 clone 下来看。AI 助手也一样------它的上下文窗口再大,也塞不下十几个仓库的代码。
根本原因就一个:AI Agent 缺少一个统一的"代码知识库"来跨仓库理解代码。
这就是 CodeKB 的出发点------做一个通用的代码知识库,把多个仓库的代码结构、语义、文档都提取出来,通过 MCP 协议暴露给 AI Agent。不是一个仓库的知识库,是多个仓库的。
整体架构:四层存储
想清楚需求后,我画了个大致的架构。核心思路是分而治之------不同类型的信息用不同的存储方式,各取所长:
scss
┌─────────────────────────────────────────┐
│ MCP Server (17个工具) │
│ query_usage / search_code / ... │
├─────────────────────────────────────────┤
│ Retrieval Layer │
│ SemanticSearch / HybridSearch / │
│ StructureQuery / GuideGenerator │
├──────┬──────┬──────────┬────────────────┤
│SQLite│Chroma│Markdown │ Skills │
│结构层 │DB向量│文档层 │ (Agent技能) │
│ │层 │ │ │
└──────┴──────┴──────────┴────────────────┘
四层各司其职:
- SQLite 结构层 :存代码的精确结构信息------类、函数、调用关系、import 语句、继承关系。适合精确查询,比如"谁调用了
QuoteService"。 - ChromaDB 向量层:存代码和文档的语义嵌入。适合模糊搜索,比如"找跟支付相关的代码"。
- Markdown 文档层:生成的架构文档、API 文档、组件使用指南。人类和 AI 都能直接读。
- Agent Skills 层:给 AI Agent 准备的"技能包",告诉它怎么在这个项目里完成特定任务。
为什么是四层而不是一层?因为早期我试过把所有东西都塞进向量数据库,结果发现精确查询(比如"找到 handle_request 函数的所有调用者")在向量数据库里做得很差。结构信息就是应该用关系型数据库存,向量数据库处理不了这种确定性查询。
反过来,只用 SQLite 也不行。想搜"和用户认证相关的代码",SQLite 只能做 LIKE '%认证%',完全比不上语义搜索。
核心流程:索引流水线
索引是整个系统的入口。一个仓库从"原始代码"变成"可查询的知识库",要经过这么一条流水线:
scss
原始代码仓库
│
▼
[1] 模块检测 (Module Detector)
│ 自动识别 monorepo 中的子模块
▼
[2] Tree-sitter 解析
│ 提取符号、调用关系、import、继承关系
▼
[3] 文档索引 (README + Markdown)
│ 轻量级,只记录元数据
▼
[4] Import 路径解析
│ 把 import 语句解析成实际文件路径
▼
[5] 代码向量化 (Code Embedding)
│ 按 symbol 边界分块 → 向量化
▼
[6] 文档向量化 (Doc Embedding)
│ 按 Markdown section 分块 → 向量化
▼
可查询的知识库
这段逻辑在 IndexOrchestrator.full_index() 里,代码其实很直白:
python
async def full_index(self, repo_name: str) -> dict:
repo = self.store.get_repo(repo_name)
repo_path = Path(repo.local_path)
# 模块检测
modules = detect_modules(repo_path, configured_dicts)
# Tree-sitter 解析:提取符号、调用、import、继承
stats = self.ts_indexer.index_repo(
repo_name, repo_path, self.config.index.exclude_patterns,
modules=modules if any(m.name for m in modules) else None,
)
# 文档元数据索引
self.store.index_docs(repo_name, repo_path)
# Import 路径解析(后处理)
self._resolve_imports(repo_name, repo_path)
# 代码向量化
emb_indexer = self._get_embedding_indexer("code_embedding")
await emb_indexer.delete_repo_vectors(repo_name)
code_count = await emb_indexer.index_repo_code(repo_name)
# 文档向量化
doc_indexer = self._get_embedding_indexer("doc_embedding")
doc_count = await doc_indexer.index_repo_docs(repo_name, repo_path)
每一步都做了状态记录,方便前端展示进度。哪一步挂了也能从状态看出来。
增量索引
实际用的时候不可能每次改几行代码就重新全量索引。所以还做了增量索引------只处理变更的文件:
python
async def incremental_index(self, repo_name: str, changed_files: list[str]) -> dict:
# 区分源码文件和文档文件
source_files = [f for f in changed_files if Path(f).suffix in source_extensions]
doc_files = [f for f in changed_files if Path(f).suffix in doc_extensions]
for rel_path in source_files:
if not file_path.exists():
# 文件被删除:清除关联数据
self.store.delete_symbols_for_file(repo_name, rel_path)
self.store.delete_calls_for_file(repo_name, rel_path)
await emb_indexer.delete_file_vectors(repo_name, rel_path)
continue
# 重新解析 + 重新向量化
self.ts_indexer.reindex_file(repo_name, file_path, rel_path)
# ... 重新 embed
增量索引配合文件监听器(File Watcher),实现了代码变更后自动更新索引。文件监听用了 debounce(2秒)+ 内容哈希变化检测,避免频繁触发无意义的重索引。
Tree-sitter:代码解析的核心
Tree-sitter 是这个项目里最核心的组件。它能把你写的代码解析成一棵语法树(CST,具体语法树),然后你就可以在这棵树上提取你想要的信息。
为什么选 Tree-sitter
我对比过几个方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 正则表达式 | 简单快速 | 不够准确,嵌套结构处理不了 |
| LSP (Language Server Protocol) | 准确、功能全 | 需要启动语言服务器,太重了 |
| AST 解析器 (ast, babel) | 精准 | 每种语言一套,Python 用 ast,JS 用 babel |
| Tree-sitter | 统一接口、多语言、增量解析 | 需要自己写提取逻辑 |
Tree-sitter 胜在"统一接口 + 多语言支持"。一套代码框架,换一个语言绑定的 grammar 就能解析另一种语言。
Language Strategy 模式
为了让不同语言的解析逻辑互不干扰,我用了一个策略模式(Strategy Pattern)。定义一个 LanguageStrategy 协议(类似 Java 的接口),每种语言实现自己的策略:
python
class LanguageStrategy(Protocol):
"""每种语言的提取策略"""
def language(self) -> ts.Language: ...
def file_extensions(self) -> set[str]: ...
def extract_symbols(self, tree, source, ...) -> list[Symbol]: ...
def extract_calls(self, tree, source, ...) -> list[CallRelation]: ...
def extract_imports(self, tree, source, ...) -> list[ImportRecord]: ...
def extract_inheritance(self, tree, source, ...) -> list[EdgeRelation]: ...
目前实现了 7 种语言:Python、JavaScript、TypeScript、Java、Kotlin、Swift、ArkTS(鸿蒙的)。
看看 Python 策略的具体实现
以 Python 为例,符号提取的核心就是遍历语法树,碰到 function_definition 就提取函数,碰到 class_definition 就提取类,然后递归进入类体找方法:
python
class PythonStrategy:
def _walk_for_symbols(self, node, source, file_path, repo_name, language_name, parent, symbols):
if node.type == "function_definition":
name = self._get_field_text(node, "name", source)
params = self._get_field_text(node, "parameters", source)
signature = f"def {name}{params}"
docstring = self._extract_docstring(node, source)
symbols.append(Symbol(
name=name,
kind="method" if parent else "function",
signature=signature,
docstring=docstring,
start_line=node.start_point[0] + 1,
end_line=node.end_point[0] + 1,
parent=parent, # 如果在类里,parent 就是类名
source=bytes(node.text).decode("utf-8", errors="replace"),
))
elif node.type == "class_definition":
name = self._get_field_text(node, "name", source)
superclasses = self._get_field_text(node, "superclasses", source)
# ... 提取类信息
# 递归进入类体,parent 设为类名
body = node.child_by_field_name("body")
if body:
for child in body.children:
self._walk_for_symbols(child, source, ..., name, symbols)
Tree-sitter 的 API 设计得很直觉------每个节点都有 type(节点类型),有 child_by_field_name(按字段名取子节点),有 start_point / end_point(行列号)。基本上对着语言的 grammar 文档写就行。
调用关系的提取也类似,找 call 类型的节点,提取 function 字段:
python
def _collect_calls_in_node(self, node, caller_name, file_path, repo_name, calls):
if node.type == "call":
func = node.child_by_field_name("function")
if func:
callee = bytes(func.text).decode("utf-8", errors="replace")
calls.append(CallRelation(
caller_name=caller_name,
callee_name=callee,
line_number=node.start_point[0] + 1,
))
for child in node.children:
self._collect_calls_in_node(child, caller_name, ...)
提取了什么
每个文件经过 Tree-sitter 解析后,会产出这几类数据:
符号(Symbol):类、函数、方法、变量、常量。包含签名、docstring、源码、行列号、父级关系。
ini
Symbol(
name="handle_request",
kind="method",
signature="def handle_request(self, req: Request) -> Response",
docstring="处理HTTP请求的主入口",
file_path="src/server/handler.py",
start_line=42, end_line=78,
parent="RequestHandler",
source="def handle_request(self, req: Request) -> Response:\n ..."
)
调用关系(CallRelation):A 函数调用了 B 函数。
ini
CallRelation(
caller_name="handle_request",
callee_name="validate_input",
caller_file="src/server/handler.py",
line_number=45
)
Import 语句(ImportRecord):文件导入了什么。
ini
ImportRecord(
file_path="src/server/handler.py",
module="src.utils.validators",
imported_names="validate_input,check_auth",
is_relative=False
)
边关系(EdgeRelation):继承、接口实现等关系。
ini
EdgeRelation(
source_symbol="RequestHandler",
target_symbol="BaseHandler",
kind="extends",
source_file="src/server/handler.py"
)
这些数据全部存进 SQLite,后面做各种查询就非常方便了。
Import 解析:把 import 语句变成实际文件路径
Tree-sitter 提取 import 语句不难,但 from src.utils.validators import validate_input 里的 src.utils.validators 到底对应仓库里的哪个文件?这是索引后需要做的一步后处理。
我写了个 ImportResolver,针对不同语言有不同的解析策略:
python
class ImportResolver:
def resolve_imports(self, repo_name: str, repo_path: Path):
# 取出所有未解析的 import
imports = self.store.get_unresolved_imports(repo_name)
for imp in imports:
lang = self._detect_language(imp.file_path)
if lang in ("python",):
resolved_path, resolved_symbol = self._resolve_python(imp, repo_path)
elif lang in ("typescript", "javascript"):
resolved_path, resolved_symbol = self._resolve_ts_js(imp, repo_path)
elif lang in ("java", "kotlin"):
resolved_path, resolved_symbol = self._resolve_java_kotlin(imp, repo_path)
elif lang == "go":
resolved_path, resolved_symbol = self._resolve_go(imp, repo_path)
if resolved_path:
self._update_import_resolution(imp, resolved_path, resolved_symbol)
Python 的解析逻辑:from .utils import foo → 同目录下找 utils.py 或 utils/__init__.py;from ..package import Bar → 上级目录找 package/__init__.py。
TypeScript 的解析稍微复杂一些,要处理 tsconfig.json 里的 paths 别名。比如 @/components/Button 可能映射到 src/components/Button.tsx。
Go 则是读 go.mod 拿到模块前缀,然后 strip 掉前缀拿到相对路径。
说实话这块做得比较粗糙。Python 的 stdlib 和第三方库目前是用启发式判断的------如果 import 路径在仓库里找不到对应的文件,就当它是外部的跳过。不是特别优雅,但对于大多数项目够用了。
向量化:代码搜索的另一条路
Tree-sitter 提取的是精确的结构信息。但很多时候你需要的不是精确查询,而是"帮我找跟 XX 类似的代码"或者"搜一下项目里跟支付相关的逻辑"。这就需要语义搜索了。
分块策略
向量化之前要做分块。代码的分块不能像文章那样按字数硬切------你不能把一个函数从中间切开。我用的策略是按 symbol 边界分块:
python
def chunk_symbols(symbols: list[Symbol]) -> list[CodeChunk]:
chunks = []
for sym in symbols:
if not sym.source:
continue
chunk = CodeChunk(
id=_chunk_id(sym.repo_name, sym.file_path, sym.name, sym.kind, sym.start_line),
name=sym.name,
kind=sym.kind,
source=sym.source,
start_line=sym.start_line,
end_line=sym.end_line,
)
chunks.append(chunk)
return chunks
每个 symbol(函数、类)就是一个 chunk。这样做的好处是搜索结果天然有语义边界------返回的一定是完整的函数或类,不会出现"半个函数"的情况。
文档(README、Markdown)的分块则是按 section 来切,用 Markdown 的标题层级作为分割点。
Embedding Provider
向量化的 Provider 做了抽象,支持两种:
python
class EmbeddingProvider(Protocol):
async def embed(self, texts: list[str]) -> list[list[float]]: ...
async def embed_query(self, text: str) -> list[float]: ...
class SentenceTransformerProvider:
"""本地 sentence-transformers,默认 all-MiniLM-L6-v2"""
...
class OpenAIEmbeddingProvider:
"""OpenAI API,默认 text-embedding-3-small"""
...
默认用本地的 all-MiniLM-L6-v2,384 维。好处是不依赖外部服务,缺点是中文代码注释的语义理解能力弱一些。也可以切换到 OpenAI 的 embedding,效果会好一些,但要花钱。
Hybrid Search:向量 + 关键词
单纯用向量搜索有个问题------它擅长语义相似,但不擅长精确匹配。比如搜 QuoteService,向量搜索可能返回 PriceService,因为语义上确实接近。但你要的就是精确的 QuoteService。
所以做了一个 Hybrid Search,把向量搜索和关键词搜索的结果用 Reciprocal Rank Fusion(RRF)融合:
python
class HybridSearch:
async def search(self, query, repo_name=None, top_k=10,
code_weight=0.7, doc_weight=0.3):
# 向量搜索
code_results = await self.semantic.search_code(query, ...)
doc_results = await self.semantic.search_docs(query, ...)
# 关键词搜索
code_keyword = _keyword_search(query, code_results)
doc_keyword = _keyword_search(query, doc_results)
# RRF 融合 4 个排序结果
fused = reciprocal_rank_fusion(
code_vector_ranking,
doc_vector_ranking,
code_keyword,
doc_keyword,
)
return [build_result(doc_id, score) for doc_id, score in fused[:top_k]]
RRF 的公式很简单:score = sum(1 / (k + rank)),k 一般取 60。它不关心原始分数的大小,只关心排名,所以不同量纲的分数(余弦相似度 vs 关键词匹配率)可以公平融合。
MCP Server:连接 AI Agent 的桥梁
知识库建好了,怎么让 AI Agent 用起来?答案是用 MCP(Model Context Protocol)。
MCP 是 Anthropic 提出的一个协议,定义了 AI Agent 和外部工具/数据源之间的通信标准。你可以把它理解为 AI 世界的"USB 接口"------只要你的服务实现了 MCP,任何支持 MCP 的 AI Agent(Claude Desktop、Cursor、Cline 等)都能直接调用。
17 个工具
我的 MCP Server 暴露了 17 个工具(Tool),大致分几类:
查询类(最常用):
query_usage:万能入口,问"怎么用 XX",按优先级链查找:README → API Guide → Component Guide → Usage Examples → 推荐search_code:语义代码搜索find_symbol:跨仓库查找符号resolve_keyword:解析模糊关键词
结构查询类:
get_structure:获取代码结构(两级查询------不带 path 返回概要,带 path 返回详情)get_symbol_detail:获取符号的完整定义 + 调用关系 + 继承关系get_file_content:读取文件内容
管理类:
list_repos、get_repo_info、list_modules、get_module_dependencies
文档/技能类:
get_architecture、list_skills、get_skilllist_doc_index、read_doc
query_usage 的优先级链设计
query_usage 是我花心思最多的一个工具。AI Agent 问"怎么用 XX",可能的回答来源有很多------README、API 文档、组件指南、代码示例。怎么决定先返回哪个?
我设计了一个"文档优先"的优先级链:
python
async def _handle_query_usage(arguments, ...):
# 优先级 1:README(如果模块有 README,直接返回)
readme_content = structure_query.get_readme(repo_name, module=module or query)
if readme_content:
return {"source": "readme", "content": readme_content}
# 优先级 2:API Guide(LLM 生成的 API 文档,有缓存)
api_result = await guide_generator.get_api_guide(repo_name, query, ...)
if api_result and not api_result.get("error"):
return {"source": "api_guide", "content": api_result}
# 优先级 3:Component Guide(如果指定了 symbol)
if symbol:
comp_result = await guide_generator.get_component_guide(repo_name, symbol, ...)
if comp_result:
return {"source": "component_guide", "content": comp_result}
# 优先级 4:Usage Examples
usage_result = await guide_generator.get_usage_examples(repo_name, symbol, ...)
...
# 优先级 5:推荐相似组件
rec_result = await guide_generator.recommend_component(repo_name, query, ...)
...
为什么 README 优先?因为 README 通常是开发者手动写的,信息密度高,可信度也最高。LLM 生成的文档虽然有缓存机制,但质量不一定比得上人类写的。
MCP 的 Server 端实现
MCP Server 的实现很简单,用的是官方的 Python SDK:
python
from mcp.server import Server
from mcp.server.stdio import stdio_server
def _create_server(config=None) -> Server:
config, settings, store, vector_store, doc_store, repo_manager = _get_services(config)
server = Server("codekb", instructions=_CODEKB_INSTRUCTIONS)
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [types.Tool(name="query_usage", ...), ...]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
result = await _handle_tool(name, arguments, ...)
return [types.TextContent(type="text", text=json.dumps(result, ...))]
return server
通过 stdio 传输------AI Agent 启动 CodeKB 作为子进程,通过标准输入输出通信。这也是 MCP 最常用的方式。
配置 AI Agent 接入
在 Claude Desktop 的配置文件里加一段就行:
json
{
"mcpServers": {
"codekb": {
"command": "codekb",
"args": ["serve"]
}
}
}
Cursor 和其他支持 MCP 的客户端也是类似的配置方式。配好之后,AI Agent 就能自动调用 CodeKB 的 17 个工具来查询代码知识库了。
配置系统:YAML + .env 双层设计
配置这一块我纠结过一阵。有些配置适合放配置文件里(比如 LLM Provider 的设置),有些适合放环境变量里(比如 API Key)。最后选了 YAML + .env 双层方案:
yaml
# codekb.yaml
data_dir: ~/.codekb
llm_providers:
openai:
provider: openai
base_url: https://api.openai.com/v1
model: gpt-4o-mini
deepseek:
provider: openai
base_url: https://api.deepseek.com
model: deepseek-chat
embedding_providers:
local:
provider: sentence-transformers
model: all-MiniLM-L6-v2
dimension: 384
assignments:
doc_generation: openai # 文档生成用哪个 LLM
code_embedding: local # 代码向量化用哪个 embedding
doc_embedding: local # 文档向量化用哪个 embedding
bash
# .env
OPENAI_API_KEY=sk-xxx
DEEPSEEK_API_KEY=sk-xxx
assignments 是一个我觉得比较实用的设计------不同的任务可以指定不同的 Provider。文档生成需要好的 LLM,可以用 GPT-4o-mini;向量化用本地的模型就够了,省钱。
LLM 调用这块用了 litellm 做统一封装。不管底层是 OpenAI、DeepSeek 还是其他兼容 OpenAI API 的服务,上层代码都不用改。
模块检测:让 Monorepo 也能用
现实中的项目经常是 monorepo------一个仓库里有好几个子包。比如前端一个 packages/ 目录下有 ui、utils、hooks 三个包。
CodeKB 做了自动的模块检测:
python
def detect_modules(repo_path, configured_modules=None):
# 优先级 1:手动配置
if configured_modules:
return [ModuleInfo(**m) for m in configured_modules]
# 优先级 2:自动检测
# 策略 A:检查 packages/、libs/、modules/ 等目录
for mono_dir_name in {"packages", "libs", "modules", "services", "apps"}:
mono_dir = repo_path / mono_dir_name
if not mono_dir.is_dir():
continue
for child in sorted(mono_dir.iterdir()):
manifest = _find_manifest(child) # 找 package.json、pyproject.toml 等
if manifest:
modules.append(ModuleInfo(name=child.name, ...))
# 策略 B:检查根目录下有 manifest 文件的子目录
# 策略 C:单模块仓库(返回空 module name,保持向后兼容)
这个检测逻辑覆盖了常见的 monorepo 结构------packages/ 目录(前端 monorepo)、services/ 目录(微服务)、以及根目录直接放多个独立项目的情况。判断标准是看子目录有没有 manifest 文件(package.json、pyproject.toml、go.mod 等)。
文件归属模块的判定用最长前缀匹配:file_to_module("packages/ui/src/Button.tsx", modules) → ui。
一些设计取舍和反思
做这个项目的过程中,有不少纠结的地方,也有一些回头看可以做得更好的地方。
查询时零 LLM 的选择
CodeKB 的查询操作不调用 LLM。所有搜索和检索都是直接从预构建的索引中读取。LLM 只在两个场景用到:
- 索引阶段:生成文档和技能包(一次性成本)
- Guide 工具:生成组件指南、API 文档(有缓存,只在首次查询时调用)
这个选择是对的。查询时调 LLM 有两个大问题:一是慢,二是贵。一个 AI Agent 在一次对话中可能调用好几次 CodeKB 的工具,每次都调 LLM 的话,延迟和成本都会爆炸。
但代价是查询的灵活性受限。用户问一些索引阶段没预见到的问题,可能得不到好的回答。这算是一个有意识的 trade-off。
两级结构查询的设计
get_structure 工具设计了两个级别:不带 path 参数返回文件级概要(每个符号只返回 name + type),带 path 参数返回文件内所有符号的完整详情。
为什么这样设计?因为一个中等规模的项目可能有几百个文件、几千个符号。如果每次都返回完整信息,token 消耗非常惊人------对 AI Agent 来说,这意味着每次查询都要"读"好几屏的内容。
两级设计让 Agent 先概览(找感兴趣的文件),再详情(获取具体信息)。一次查询可能从几千个 token 缩减到几百个。
对于 TypeScript/JavaScript 项目还做了特殊处理------概览模式只返回 exported 的符号,避免内部实现细节淹没结果。这个优化是从实际使用中发现的:一个 React 组件文件可能有 20 个内部函数,但对外暴露的就一个 export default。
Guide 缓存
Guide 工具(组件指南、API 文档等)会用 LLM 生成内容,这个内容做了缓存------存在 SQLite 的 guide_cache 表里。同一个组件的指南不会重复生成。
但缓存失效策略做得比较粗糙------目前是跟着代码变更走的。文件变了,相关的缓存就清掉。理想情况下应该做更细粒度的失效(比如只在一个类的公共 API 变了才失效),但这个目前没做。
还有什么没做好
坦白说,没做好的地方比做好的多:
-
语言覆盖不够。7 种语言听起来不少,但 C/C++、Rust 这些没有,PHP、Ruby 也是空的。Tree-sitter 的策略模式让扩展不难,但每种语言都需要写一段提取逻辑,工作量不小。
-
Import 解析的准确性 。目前的启发式方法在简单项目里工作还行,复杂一点的就吃力了。比如 Python 的
sys.path修改、Java 的 Gradle 依赖管理、TypeScript 的 monorepo workspace alias,这些都没处理。 -
跨仓库追踪还没做好 。目前虽然支持索引多个仓库,也做了
find_symbol跨仓库符号搜索,但仓库之间的调用关系(服务 A 的哪个函数调了服务 B 的哪个接口)还没有打通。ImportResolver只能解析单仓库内的路径,跨仓库的依赖还是靠人工。这对于微服务架构的用户来说是一个明显的短板。 -
错误处理不够精细。索引流水线中任何一步失败,整个索引就标记为 error。实际上应该有更细粒度的恢复策略------比如 Tree-sitter 解析失败的文件可以跳过,不影响其他文件。
-
向量数据库没有版本管理。ChromaDB 不支持 schema 变更的平滑迁移。如果改了 chunk 的 ID 生成逻辑,只能删了重来。
技术选型一览
最后列一下用到的技术栈:
| 组件 | 技术选择 | 为什么 |
|---|---|---|
| 代码解析 | Tree-sitter | 统一接口、多语言、增量解析 |
| 向量数据库 | ChromaDB | Python 原生、轻量、嵌入式 |
| 关系数据库 | SQLite | 零部署、单文件、够用 |
| Embedding | sentence-transformers / OpenAI | 本地优先,可选云端 |
| LLM | litellm(OpenAI/DeepSeek 兼容) | 统一接口、多 Provider |
| MCP Server | mcp Python SDK | 官方 SDK、stdio 传输 |
| CLI | Typer + Rich | 好用、好看 |
| 数据模型 | Pydantic v2 | 类型安全、验证、序列化 |
| 配置 | YAML + pydantic-settings | 人可读 + 环境变量 |
| 异步 | asyncio + pytest-asyncio | Embedding 和 LLM 调用需要异步 |
整体技术栈偏 Python 生态,选型原则是尽量用成熟的库,不重复造轮子。Tree-sitter、ChromaDB、Pydantic、litellm 这些都是各自领域里比较好用的选择。
写在最后
CodeKB 是一个半成品,但它解决了一个我觉得很重要的问题:怎么让 AI Agent 真正理解你的代码项目。
光把代码文件丢给 AI 是不够的。你需要把代码的结构信息、语义信息、文档信息预先处理好,然后用一种 AI 能理解和调用的方式暴露出去。MCP 协议恰好提供了这个桥梁。
如果你也在做类似的事情,几个建议:
- 结构信息和语义信息要分开存储。SQL 做精确查询,向量做模糊搜索,各司其职。
- 分块策略要尊重代码结构。别按字数切代码,按函数/类的边界切。
- 查询时尽量零 LLM。预计算 + 缓存 > 实时生成。
- 先用好 Tree-sitter。它是代码解析的基石,投资在这个上面回报最高。
代码开源在 GitHub:github.com/mufans/ai-c...,欢迎来看看。如果有兴趣一起完善,也欢迎提 Issue 和 PR。
本文来自实际项目开发经验分享,CodeKB 仍在开发中,功能持续完善。