昨天(Day 9)我們利用 AST 的 CodebaseASTVisitor,成功把原始碼轉成了包含類別階層(Qualified Name)與 Import 別名的結構化資料。
但若每次使用者在 CLI 提問或 Ollama 調用工具時,都要重新打開檔案、跑一遍 AST 解析,效率會隨著專案變大而雪崩。更重要的是,AST 只存在記憶體中,無法進行跨檔案的高速關聯查詢。
今天我們的任務很明確:把解析出來的 AST Symbol 與 Import 關聯,存入零外部依賴、輕量極速的本機 SQLite 資料庫中。
零外部服務:不需要另外起 Docker、開 port 或管理連線池,純靠 Python 內建的 sqlite3。
不污染目標專案:資料庫檔案固定放在 Agent 自己的 data/codebase.db,目標 repository 永遠保持 100% 乾淨唯讀。
保留多元性(不武斷去重):程式碼庫中常有同名變數(例如 COLLECTION_NAME 在設定檔與 preflight 腳本各自有定義),關聯式資料表能把「同名但不同路徑」的事實完整記錄下來,而不是粗暴地覆蓋掉。
第一版我們先建立兩張核心表:symbols 與 imports:
CREATE TABLE IF NOT EXISTS symbols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
kind TEXT NOT NULL, -- "class" | "function" | "method" | "module"
name TEXT NOT NULL, -- 短名稱,如 "run"
qualified_name TEXT NOT NULL, -- 限定名稱,如 "Pipeline.run"
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
docstring TEXT
);
CREATE TABLE IF NOT EXISTS imports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_path TEXT NOT NULL, -- 呼叫端的檔案路徑
module_name TEXT NOT NULL, -- 被匯入的模組名稱
imported_name TEXT, -- 具體匯入的 symbol (若有)
alias TEXT, -- 本地別名 (若有)
line INTEGER NOT NULL
);
SymbolIndexer 與資料寫入我們在 app/codebase.py 中實作 SQLite 的寫入與查詢邏輯:
# app/codebase.py (新增 SQLite 索引層)
import sqlite3
from pathlib import Path
from typing import List, Dict, Any, Optional
class SymbolIndexer:
def __init__(self, db_path: str = "data/codebase.db"):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(str(self.db_path))
self.conn.row_factory = sqlite3.Row
self._init_db()
def _init_db(self):
with self.conn:
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS symbols (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
kind TEXT NOT NULL,
name TEXT NOT NULL,
qualified_name TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
docstring TEXT
);
CREATE TABLE IF NOT EXISTS imports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_path TEXT NOT NULL,
module_name TEXT NOT NULL,
imported_name TEXT,
alias TEXT,
line INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
CREATE INDEX IF NOT EXISTS idx_imports_module ON imports(module_name);
""")
def rebuild(self, symbols: List[Any], imports: List[Any]):
"""清空舊資料並批次寫入新索引"""
with self.conn:
self.conn.execute("DELETE FROM symbols")
self.conn.execute("DELETE FROM imports")
# 寫入 symbols
symbol_rows = [
(s.path, s.kind, s.name, s.qualified_name, s.start_line, s.end_line, s.docstring)
for s in symbols
]
self.conn.executemany("""
INSERT INTO symbols (path, kind, name, qualified_name, start_line, end_line, docstring)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", symbol_rows)
# 寫入 imports
import_rows = [
(i.from_path, i.module_name, i.imported_name, i.alias, i.line)
for i in imports
]
self.conn.executemany("""
INSERT INTO imports (from_path, module_name, imported_name, alias, line)
VALUES (?, ?, ?, ?, ?)
""", import_rows)
def find_symbol(self, name: str) -> List[Dict[str, Any]]:
"""依照名稱或 Qualified Name 查詢 symbol,保留所有同名結果"""
cur = self.conn.cursor()
cur.execute("""
SELECT path, kind, name, qualified_name, start_line, end_line
FROM symbols
WHERE name = ? OR qualified_name = ?
ORDER BY path, start_line
""", (name, name))
return [dict(row) for row in cur.fetchall()]
在 tests/unit/test_symbol_indexer.py 中驗證資料庫邏輯:
# tests/unit/test_symbol_indexer.py
from dataclasses import dataclass
from app.codebase import SymbolIndexer
@dataclass
class DummySymbol:
path: str
kind: str
name: str
qualified_name: str
start_line: int
end_line: int
docstring: str = ""
def test_symbol_indexing_and_duplicate_names(tmp_path):
db_file = tmp_path / "test.db"
indexer = SymbolIndexer(db_path=str(db_file))
# 模擬兩個不同檔案中定義了同名的 COLLECTION_NAME
symbols = [
DummySymbol("src/rag_common.py", "constant", "COLLECTION_NAME", "COLLECTION_NAME", 12, 12),
DummySymbol("src/preflight.py", "constant", "COLLECTION_NAME", "COLLECTION_NAME", 5, 5),
]
indexer.rebuild(symbols=symbols, imports=[])
results = indexer.find_symbol("COLLECTION_NAME")
assert len(results) == 2
assert results[0]["path"] == "src/preflight.py" or results[1]["path"] == "src/preflight.py"
assert results[0]["start_line"] in (5, 12)
執行測試確認綠燈:
uv run pytest tests/unit/test_symbol_indexer.py -v
mobileai-local-rag將索引器串上 app.cli 後,在目標專案執行索引與查詢:
uv run python -m app.cli index
終端機報告:
{
"indexed_files": 7,
"indexed_symbols": 23,
"indexed_imports": 28,
"skipped_files": 0,
"parse_errors": []
}
接著查詢常數 COLLECTION_NAME:
uv run python -m app.cli symbol COLLECTION_NAME
輸出結果:
Found 2 symbols:
1. src/rag_common.py:12 (constant) COLLECTION_NAME
2. src/preflight.py:5 (constant) COLLECTION_NAME
工具忠實列出兩筆定義,沒有擅自選一筆抹掉另一個。
今天我們完成了從「記憶體語法樹」到「本機關聯資料庫」的持久化串接:
結構落地:AST 結果寫入 SQLite,查詢時間壓到毫秒級。
面對現實程式碼庫:支援同名 Symbol 同時存在,不隨意覆蓋真實定義。
無副作用隔離:所有索引資料留在庫外 data/,受測 Repo 保持唯讀乾淨。
既然資料庫裡已經有了 28 條 Import 記錄,明天在 Day 11 中,我們將利用 imports 表建立直接依賴關聯圖(Direct Import Graph),回答那個經典問題:「當我動了這個模組,哪些檔案直接受波及?」