본문 바로가기
WIKI 기술 지식 베이스

코드 인덱싱

원문 보기 위키 갱신

코드 인덱싱 (Code Indexing)

AI 코딩 에이전트는 파일 이름이나 grep이 아니라 코드베이스를 의미적으로 검색할 수 있을 때("인증 로직 찾아 줘") 훨씬 잘 동작해요. Turso 기반 코드 인덱스는 식별자 전문 검색과 임베딩 벡터 유사도 검색을 데이터베이스 파일 하나에 담을 수 있어요.

출처: 문서

본문

AI 코딩 에이전트는 코드베이스를 의미적으로 검색할 수 있을 때("인증 로직 찾아 줘") 파일 이름이나 grep 검색보다 훨씬 잘 동작해요. Turso로 만든 코드 인덱스는 식별자 위주의 전문 검색(FTS)과 임베딩 벡터 유사도 검색을 결합할 수 있고, 이 모든 게 하나의 임베디드 데이터베이스 안에서 돌아가요.

이 가이드는 FTS와 벡터 검색을 모두 갖춘 코드 인덱서를 만드는 스키마와 쿼리를 다뤄요.

스키마 (Schema)

CREATE TABLE IF NOT EXISTS codebases (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    root_path   TEXT NOT NULL UNIQUE,
    name        TEXT NOT NULL DEFAULT '',
    indexed_at  INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS chunks (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    codebase_id     INTEGER NOT NULL REFERENCES codebases(id),
    file_path       TEXT NOT NULL,
    chunk_key       TEXT NOT NULL UNIQUE,
    language        TEXT NOT NULL,
    kind            TEXT NOT NULL,
    name            TEXT NOT NULL DEFAULT '',
    signature       TEXT NOT NULL DEFAULT '',
    snippet         TEXT NOT NULL,
    start_line      INTEGER NOT NULL,
    end_line        INTEGER NOT NULL,
    file_hash       TEXT NOT NULL,
    indexed_at      INTEGER NOT NULL,
    embedding       F8_BLOB(384),
    embedding_model TEXT DEFAULT ''
);

CREATE TABLE IF NOT EXISTS indexed_files (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    codebase_id INTEGER NOT NULL REFERENCES codebases(id),
    file_path   TEXT NOT NULL,
    file_hash   TEXT NOT NULL,
    chunk_count INTEGER NOT NULL DEFAULT 0,
    indexed_at  INTEGER NOT NULL,
    UNIQUE(codebase_id, file_path)
);

구성 요소가 어떻게 맞물리는지

  • **codebases**는 각 프로젝트 루트를 등록해요. 하나의 데이터베이스로 여러 코드베이스를 인덱싱할 수 있어요.
  • **chunks**는 소스 파일에서 뽑아낸 개별 의미 단위 — 함수, 구조체, 클래스, impl 블록 등 — 를 저장해요. 각 청크는 name, signature, 코드 snippet, 라인 범위, 그리고 (선택적으로) 벡터 embedding을 가져요. chunk_key는 업서트(upsert) 연산을 위한 고유 식별자(예: file_path::kind::name)예요.
  • **indexed_files**는 어떤 파일이 인덱싱됐는지와 그 콘텐츠 해시를 추적해서 증분 재인덱싱을 가능하게 해요 — 바뀐 파일만 다시 처리해요.

연결 (Connecting)

FTS 인덱스 기능은 연결 시점에 실험적 플래그가 필요해요:

import { connect } from "@tursodatabase/database";

const db = await connect(".index/code.db", {
  experimental: ["index_method"],
});
await db.exec(SCHEMA);

experimental: ["index_method"] 플래그는 Turso의 USING fts 인덱스 문법과 fts_match() / fts_score() 함수를 활성화해요.

파일 인덱싱하기

청크 업서트하기

파일을 파싱했으면 그 청크들을 업서트해요. 충돌 시 embedding = NULL로 설정하면 내용이 바뀌었을 때 강제로 재임베딩하게 돼요:

INSERT INTO chunks (codebase_id, file_path, chunk_key, language, kind, name,
                    signature, snippet, start_line, end_line, file_hash, indexed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chunk_key) DO UPDATE SET
  language = excluded.language,
  kind = excluded.kind,
  name = excluded.name,
  signature = excluded.signature,
  snippet = excluded.snippet,
  start_line = excluded.start_line,
  end_line = excluded.end_line,
  file_hash = excluded.file_hash,
  indexed_at = excluded.indexed_at,
  embedding = NULL,
  embedding_model = '';

인덱싱된 파일 추적하기

INSERT INTO indexed_files (codebase_id, file_path, file_hash, chunk_count, indexed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(codebase_id, file_path) DO UPDATE SET
  file_hash = excluded.file_hash,
  chunk_count = excluded.chunk_count,
  indexed_at = excluded.indexed_at;

바뀌지 않은 파일 건너뛰기

파일을 파싱하기 전에 바뀌었는지 확인해요:

SELECT file_hash FROM indexed_files WHERE codebase_id = ? AND file_path = ?;

해시가 같다면 그 파일은 완전히 건너뛰면 돼요.

임베딩 저장하기

임베딩은 Turso의 vector8() 함수로 int8 양자화 벡터로 저장할 수 있어요. 저장 공간이 1,536바이트(float32, 384차원)에서 395바이트로 줄어들어요:

UPDATE chunks SET embedding = vector8(?), embedding_model = ? WHERE chunk_key = ?;

vector8()의 파라미터는 JSON 문자열화된 float 배열이에요. 양자화는 Turso가 내부적으로 처리해요.

임베딩이 필요한 청크 찾기:

SELECT chunk_key, name, signature, file_path, kind, snippet
FROM chunks
WHERE codebase_id = ? AND (embedding IS NULL OR embedding_model != ?)
LIMIT ?;

Turso는 SQLite의 fts5 가상 테이블과 별개로, 가중치 적용 BM25 스코어링을 갖춘 FTS 인덱스를 제공해요.

FTS 인덱스 만들기

코드베이스마다 FTS 테이블을 만들고 chunks로 채워요:

-- Create the FTS table
CREATE TABLE IF NOT EXISTS fts_1 (
    chunk_id    INTEGER NOT NULL REFERENCES chunks(id) ON DELETE CASCADE,
    name        TEXT NOT NULL DEFAULT '',
    signature   TEXT NOT NULL DEFAULT ''
);

-- Populate from chunks
INSERT INTO fts_1 (chunk_id, name, signature)
SELECT id, name, signature FROM chunks WHERE codebase_id = 1;

-- Create the FTS index with weighted columns
CREATE INDEX IF NOT EXISTS idx_fts_1 ON fts_1
    USING fts (name, signature)
    WITH (
        tokenizer = 'default',
        weights = 'name=5.0,signature=3.0'
    );

weights 파라미터가 BM25 스코어링을 조절해요 — 여기서는 함수/타입 name에 걸린 매치를 5배, signature 매치를 3배 가중했어요.

FTS로 검색하기

SELECT chunk_id, fts_score(name, signature, ?1) AS score
FROM fts_1
WHERE fts_match(name, signature, ?1)
ORDER BY score DESC
LIMIT ?;

그다음 전체 청크 데이터를 가져와요:

SELECT chunk_key, file_path, name, kind, signature, snippet, start_line, end_line
FROM chunks WHERE id = ?;

의미적/자연어 질의에는 벡터 코사인 거리를 사용해요:

SELECT chunk_key, file_path, name, kind, signature, snippet, start_line, end_line,
       vector_distance_cos(embedding, vector8(?)) AS distance
FROM chunks
WHERE embedding IS NOT NULL
ORDER BY distance ASC
LIMIT ?;

점수는 1 - distance예요(코사인 거리를 코사인 유사도로 바꾼 값).

가장 좋은 결과를 내려면 Reciprocal Rank Fusion(RRF)으로 두 방식을 결합해요. FTS 쿼리와 벡터 쿼리를 각각 실행한 다음, 애플리케이션 코드에서 결과를 합쳐요:

// Run both searches
const ftsResults = await ftsSearch(query, limit);
const vecResults = await vectorSearch(queryEmbedding, limit);

// Reciprocal Rank Fusion
const k = 60; // RRF constant
const scores = new Map();

ftsResults.forEach((r, i) => {
  const key = r.chunk_key;
  scores.set(key, (scores.get(key) || 0) + 1 / (k + i + 1));
});

vecResults.forEach((r, i) => {
  const key = r.chunk_key;
  scores.set(key, (scores.get(key) || 0) + 1 / (k + i + 1));
});

// Sort by combined score
const merged = [...scores.entries()]
  .sort((a, b) => b[1] - a[1])
  .slice(0, limit);

오래된 파일 정리하기

코드베이스에서 파일이 삭제되면 관련 청크와 기록을 정리해요:

DELETE FROM chunks WHERE codebase_id = ? AND file_path = ?;
DELETE FROM indexed_files WHERE codebase_id = ? AND file_path = ?;

오래된 데이터를 지운 다음에는 인덱스 정합성을 위해 FTS 테이블을 다시 만들어요:

DROP TABLE IF EXISTS fts_1;
-- Then recreate and repopulate as shown above

핵심 설계 포인트

  • 증분 인덱싱은 파일 해시를 통해 구현돼요. 바뀐 파일만 다시 파싱하고 다시 임베딩하기 때문에 큰 코드베이스에서도 업데이트가 빨라요.
  • 두 가지 검색 모드 — 식별자/키워드 조회용 FTS와 의미/자연어 질의용 벡터 검색 — 가 각기 다른 용도를 커버해요. 하이브리드 검색은 둘을 결합해요.
  • 가중치 FTS(name=5.0, signature=3.0)는 결과가 함수와 타입 이름 쪽으로 쏠리게 해요. 개발자가 주로 찾는 게 바로 그거거든요.
  • vector8()로 만드는 int8 양자화 벡터는 float32 대비 저장 공간을 75% 줄이면서 검색 품질에는 거의 영향을 주지 않아요.
  • 모든 것이 하나의 임베디드 데이터베이스 파일 안에서 동작하고 외부 서비스가 필요 없어요.

예시 (Example)

codemogger는 이 패턴을 AI 코딩 에이전트용 코드 인덱서로 구현한 MCP 서버예요.

더 알아보기 (Learn more)