벡터 저장소 — 임베딩을 미리 계산해 저장하고 빠르게 검색한다
Article
17강 끝에서 짚고 넘어간 문제가 있다. embed.ts 의 검색 함수는 질문이 들어올 때마다 저장된 청크 9개를 전부 다시 벡터로 계산했다. 청크 9개는 눈 깜짝할 새 끝나지만, 문서가 900개, 9만 개라면 얘기가 다르다. 매번 같은 계산을 반복하는 건 명백한 낭비다.
오늘은 이 낭비를 없앤다. 벡터를 한 번만 계산해 저장하고, 검색할 때는 저장된 벡터를 그대로 꺼내 쓴다.
지금 방식이 왜 낭비인가
17강의 search 함수를 다시 보자.
const scored = chunks
.map((c) => ({ chunk: c, score: cosineSimilarity(queryVector, embed(c.text)) }))
.sort((a, b) => b.score - a.score);
embed(c.text) 가 바로 그 낭비다. 청크의 텍스트는 어제도, 오늘도, 내일도 안 바뀐다. 그런데도 질문이 들어올 때마다 9개 청크 전부를 처음부터 다시 벡터로 계산하는데, 바뀌지 않는 걸 매번 다시 계산하는 셈이라 캐싱으로 없앨 수 있는 전형적인 낭비다.
- store.ts청크를 한 번 임베딩해 vectors.json 에 저장 (build 단계)
- vectors.json청크 텍스트 + 미리 계산된 벡터
- search.ts저장된 벡터를 불러와 질문 벡터와만 비교 (검색 단계)
lesson-17 의 docs 폴더를 그대로 가져와 lesson-18 을 만든다. 공용 함수(청킹, 임베딩, 코사인 유사도)는 lib.ts 하나로 모아, store.ts 와 search.ts 가 같이 가져다 쓰게 한다.
저장소를 만든다 — store.ts
문서를 읽어 청크로 자르고, 각 청크를 임베딩해 파일에 쓴다. Claude 는 여기서도 한 번도 부르지 않는다 — 우리가 직접 만든 embed 함수만 쓴다.
import { writeFile } from "node:fs/promises";
import { loadAndChunkDocs, embed, type StoredChunk } from "./lib.ts";
const chunks = await loadAndChunkDocs("./docs");
console.log(`청크 ${chunks.length}개를 임베딩하는 중...`);
const stored: StoredChunk[] = chunks.map((chunk) => ({
...chunk,
vector: embed(chunk.text),
}));
await writeFile("./vectors.json", JSON.stringify(stored, null, 2));
console.log(`벡터 저장소 생성 완료: vectors.json`);
console.log(` 청크 수: ${stored.length}`);
console.log(` 차원: ${stored[0].vector.length}`);
StoredChunk 는 17강의 Chunk(source, index, text)에 vector: number[] 를 하나 더 붙인 타입이다. lib.ts 에 17강 코드를 그대로 옮겨뒀다 — loadAndChunkDocs, embed, cosineSimilarity 전부 내용은 안 바뀌었다.
npx tsx store.ts
- 청크 9개를 임베딩하는 중...
- 벡터 저장소 생성 완료: vectors.json
- 청크 수: 9
- 차원: 256
실제로 vectors.json 파일이 생겼는지 확인해보자.
ls -la vectors.json
- -rw-r--r-- 1 root root 36666 vectors.json
청크 9개의 텍스트와 256차원 벡터를 담은 JSON 파일이 36KB 다. 이 파일이 바로 우리의 (아주 작은) 벡터 저장소다.
검색을 바꾼다 — search.ts
이제 검색은 vectors.json 을 읽어오기만 하면 된다. 질문만 새로 임베딩하고, 청크 벡터는 저장된 걸 그대로 쓴다.
import { readFile } from "node:fs/promises";
import { loadAndChunkDocs, embed, cosineSimilarity, type StoredChunk } from "./lib.ts";
async function loadStore(): Promise<StoredChunk[]> {
const raw = await readFile("./vectors.json", "utf-8");
return JSON.parse(raw);
}
function searchStore(store: StoredChunk[], query: string, topK = 3) {
const queryVector = embed(query);
return store
.map((c) => ({ chunk: c, score: cosineSimilarity(queryVector, c.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
searchStore 안에서 embed 가 호출되는 건 query 한 번뿐이다. c.vector 는 이미 계산되어 저장된 값을 그대로 읽는다. 17강의 search 와 비교하면 딱 이 부분이 다르다.
npx tsx search.ts
- [벡터 저장소 검색] 저장된 9개 벡터에서 top-3
- 0.3828 [연차-휴가-정책.md #2] 미사용 연차는 원칙적으로 소멸하지만, 회사 사정으로 사...
- 0.3473 [연차-휴가-정책.md #1] 연차 사용을 원하는 직원은 사용 예정일 최소 3일 전까...
- 0.2541 [연차-휴가-정책.md #0] # 연차 휴가 정책 입사 1년 차부터 연 15일의 연...
17강에서 봤던 것과 순위·점수가 똑같다. 당연하다, embed 함수도 청크 내용도 안 바뀌었으니 계산 결과는 같다. 바뀐 건 그 값을 매번 새로 계산하느냐, 저장해둔 걸 재사용하느냐뿐이다.
진짜 얼마나 빨라졌나 — 실측 벤치마크
말로만 "빨라졌다"고 하면 이 편의 원칙(지어낸 출력 금지)에 어긋난다. 직접 재본다. 같은 질문으로 검색을 500번 반복해서, 17강 방식(매번 재임베딩)과 오늘 방식(저장된 벡터 재사용)의 시간을 performance.now() 로 잰다.
const ROUNDS = 500;
const rawChunks = await loadAndChunkDocs("./docs");
const t0 = performance.now();
for (let i = 0; i < ROUNDS; i++) {
const queryVector = embed(query);
rawChunks
.map((c) => ({ chunk: c, score: cosineSimilarity(queryVector, embed(c.text)) }))
.sort((a, b) => b.score - a.score);
}
const t1 = performance.now();
const t2 = performance.now();
for (let i = 0; i < ROUNDS; i++) {
searchStore(store, query, 3);
}
const t3 = performance.now();
- [벤치마크] 같은 질문으로 검색을 500번 반복
- 17강 방식 (매번 청크 재임베딩): 52.60ms 총, 회당 0.1052ms
- 오늘 방식 (저장된 벡터 재사용): 5.16ms 총, 회당 0.0103ms
- 속도 차이: 10.2배
청크 9개짜리 장난감 문서에서도 10배 차이가 났다. 청크가 늘어날수록 이 차이는 더 벌어지는데, 17강 방식은 청크 수에 비례해 매번 다시 계산할 게 늘어나지만 오늘 방식은 검색할 때마다 질문 하나만 새로 임베딩하면 되기 때문이다.
지금까지 만든 것
- my-ai-assistant
- docs/
- lib.ts
- store.ts
- search.ts
- vectors.json
- .env.example
- package.json
정리하면
임베딩을 미리 계산해 저장해두면, 검색할 때는 질문 하나만 새로 계산하면 된다. 청크 9개에서도 10배가 실측으로 확인됐고, 청크가 늘수록 이 차이는 더 커진다.
- 01
17강 방식의 낭비를 짚었다
검색할 때마다 안 바뀌는 청크를 전부 다시 임베딩하고 있었다.
- 02
store.ts 로 벡터를 미리 계산해 저장했다
청크 9개를 임베딩해 vectors.json(36KB)에 썼다.
npx tsx store.ts - 03
search.ts 로 저장된 벡터를 재사용해 검색했다
질문만 새로 임베딩하고, 청크 벡터는 파일에서 읽는다.
npx tsx search.ts - 04
재계산 방식과 실측으로 비교했다
500회 반복 벤치마크에서 10.2배 차이를 직접 확인했다.
지금까지 우리는 문서를 자르고(16강), 벡터로 바꾸고(17강), 빠르게 찾는 법(18강)까지 만들었다. 그런데 검색 결과는 아직 청크 원문 그대로다. 사용자는 "미사용 연차는... 5일까지 이월할 수 있다" 같은 문서 조각을 받을 뿐, 자연스러운 답을 받지 못한다. 다음 19강에서는 이 검색 결과를 Claude 에게 넘겨, 진짜 문장으로 된 답을 만들게 한다. RAG 의 R(Retrieval)에 이어 G(Generation)를 붙이는 순간이다.