iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
AI Engineering

30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰系列 第 10 篇

Day 10:把 Symbol 變成可查的索引:SQLite 持久化與同名衝突處理

  • 分享至 

  • xImage
  •  

昨天(Day 9)我們利用 AST 的 CodebaseASTVisitor,成功把原始碼轉成了包含類別階層(Qualified Name)與 Import 別名的結構化資料。

但若每次使用者在 CLI 提問或 Ollama 調用工具時,都要重新打開檔案、跑一遍 AST 解析,效率會隨著專案變大而雪崩。更重要的是,AST 只存在記憶體中,無法進行跨檔案的高速關聯查詢。

今天我們的任務很明確:把解析出來的 AST Symbol 與 Import 關聯,存入零外部依賴、輕量極速的本機 SQLite 資料庫中。

為什麼選 SQLite?(而不是向量庫或 JSON)

  • 零外部服務:不需要另外起 Docker、開 port 或管理連線池,純靠 Python 內建的 sqlite3。

  • 不污染目標專案:資料庫檔案固定放在 Agent 自己的 data/codebase.db,目標 repository 永遠保持 100% 乾淨唯讀。

  • 保留多元性(不武斷去重):程式碼庫中常有同名變數(例如 COLLECTION_NAME 在設定檔與 preflight 腳本各自有定義),關聯式資料表能把「同名但不同路徑」的事實完整記錄下來,而不是粗暴地覆蓋掉。

資料表 Schema 設計

第一版我們先建立兩張核心表: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

工具忠實列出兩筆定義,沒有擅自選一筆抹掉另一個。

總結與下一步

今天我們完成了從「記憶體語法樹」到「本機關聯資料庫」的持久化串接:

  1. 結構落地:AST 結果寫入 SQLite,查詢時間壓到毫秒級。

  2. 面對現實程式碼庫:支援同名 Symbol 同時存在,不隨意覆蓋真實定義。

  3. 無副作用隔離:所有索引資料留在庫外 data/,受測 Repo 保持唯讀乾淨。

既然資料庫裡已經有了 28 條 Import 記錄,明天在 Day 11 中,我們將利用 imports 表建立直接依賴關聯圖(Direct Import Graph),回答那個經典問題:「當我動了這個模組,哪些檔案直接受波及?」


上一篇
Day 9:用 AST 解析 Python:拒絕 Regex 瞎猜,搞定別名與層級
下一篇
Day 11先做得到的依賴圖:基於 SQLite 查詢直接 Import 關係
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言