포스트

RAG 기초 (4) - Vector Store: 저장하고 찾아오기

numpy 검색의 한계에서 출발해 Chroma에 chunk를 저장하고 top-k 검색과 메타데이터 필터를 쓰는 방법, 재색인 스크립트 구성을 다룹니다.

RAG 기초 (4) - Vector Store: 저장하고 찾아오기

RAG 기초 시리즈의 4편입니다. 전체 목차는 0편에 있습니다.

numpy로도 되는데

3편의 검색은 다섯 줄이었습니다. 그런데도 별도 저장소를 쓰는 이유는 그 다섯 줄이 감당하지 못하는 것들 때문입니다.

한계내용
영속성프로세스를 끄면 벡터가 사라집니다. 서버를 재시작할 때마다 전체 문서를 다시 임베딩해야 합니다
증분 갱신문서 하나가 바뀌었을 때 그 문서의 chunk만 갈아끼우는 코드를 직접 짜야 합니다
메타데이터 필터“배송 문서 안에서만 검색”을 하려면 인덱스 관리 코드가 필요합니다
규모chunk가 수십만 개가 되면 전체 내적이 매 질의마다 부담이 됩니다

vector store는 이 네 가지를 대신해 줍니다. 이 시리즈에서는 로컬 파일로 동작하고 설치가 간단한 Chroma를 씁니다.

저장하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import chromadb
from sentence_transformers import SentenceTransformer
from chunking import build_chunks          # 2편

EMBED_MODEL = "jhgan/ko-sroberta-multitask"
model = SentenceTransformer(EMBED_MODEL)

client = chromadb.PersistentClient(path="./chroma")      # ① 디스크에 남는다
collection = client.get_or_create_collection(
    name="policy",
    metadata={"hnsw:space": "cosine"},                   # ② 거리 기준을 코사인으로
)

chunks = build_chunks()
collection.upsert(                                       # ③ 같은 id면 덮어쓴다
    ids=[c["id"] for c in chunks],
    documents=[c["text"] for c in chunks],
    embeddings=model.encode(
        [c["text"] for c in chunks], normalize_embeddings=True
    ).tolist(),
    metadatas=[{"source": c["source"], "title": c["title"]} for c in chunks],
)

print(collection.count())   # 7
  • PersistentClient는 지정한 경로에 데이터를 남깁니다. 다음 실행에서 그대로 읽힙니다
  • ② 기본 거리 기준은 코사인이 아니므로 명시합니다. 이 설정은 collection을 만들 때만 적용되고 나중에 바꿀 수 없습니다
  • add 대신 upsert를 쓰면 같은 id를 다시 넣을 때 덮어씁니다. 재색인 스크립트를 반복 실행해도 중복이 쌓이지 않습니다

id를 membership-0처럼 문서와 순번으로 만들어 둔 2편의 결정이 여기서 쓰입니다. 문서가 바뀌면 그 문서의 id만 다시 넣으면 됩니다.

찾아오기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def search(query: str, k: int = 3, source: str | None = None):
    qvec = model.encode(query, normalize_embeddings=True).tolist()
    result = collection.query(
        query_embeddings=[qvec],
        n_results=k,
        where={"source": source} if source else None,
    )
    return [
        {
            "text": doc,
            "source": meta["source"],
            "score": 1 - dist,          # 코사인 거리를 유사도로 되돌린다
        }
        for doc, meta, dist in zip(
            result["documents"][0], result["metadatas"][0], result["distances"][0]
        )
    ]

for hit in search("골드 등급이면 배송비가 무료인가요?"):
    print(f"{hit['score']:.3f}  {hit['source']}  {hit['text'][:40]}...")
# 0.734  membership.md  ## 등급별 혜택 적립률은 실버 1%, 골드 2%...
# 0.612  shipping.md    # 배송 정책 ## 배송비 기본 배송비는 3,000원...
# 0.401  refund.md      ## 단순 변심 상품 수령일로부터 7일 이내에...

query는 결과를 리스트의 리스트로 돌려줍니다. 질문을 여러 개 한 번에 넣을 수 있기 때문이고, 하나만 넣었으므로 [0]으로 꺼냅니다.

distances는 거리라서 작을수록 가깝습니다. 코사인 공간에서는 1 - 거리3편에서 계산한 코사인 유사도와 같습니다. 유사도로 뒤집어 두면 사람이 읽기 편하고 앞 편의 숫자와도 비교됩니다.

메타데이터 필터

where로 검색 범위를 좁힙니다.

1
2
for hit in search("며칠까지 가능한가요?", source="refund.md"):
    print(hit["source"], hit["text"][:30])

“며칠까지”라는 질문은 환불 7일, 하자 30일, 배송 2~3일 어디에나 걸립니다. 사용자가 환불 화면에서 물었다는 맥락을 알고 있다면 문서를 좁히는 것만으로 정확도가 크게 오릅니다.

실무에서 자주 쓰는 필터는 문서 종류, 작성일, 접근 권한입니다. 특히 권한은 검색 단계에서 걸러야 합니다. 볼 수 없는 문서의 내용을 프롬프트에 넣으면 모델이 그대로 답해 버립니다.

k를 몇으로 둘까

가져올 chunk 개수입니다.

k결과
1~2정확한 chunk 하나만 필요한 단순 질문에 충분합니다. 놓치면 답이 아예 나오지 않습니다
3~5대부분의 경우에 무난합니다. 이 시리즈는 3을 씁니다
10 이상관련 없는 내용이 섞여 답이 흐려지고 입력 토큰이 늡니다

k를 늘리면 정답 chunk가 포함될 확률은 올라가지만 답변 품질은 어느 지점부터 떨어집니다. 두 지표를 나눠 재는 방법이 7편에 있습니다. k를 늘린 뒤 순위를 다시 매겨 상위 몇 개만 남기는 방법은 6편의 reranking입니다.

색인 스크립트로 정리하기

여기까지를 다시 돌릴 수 있는 스크립트 하나로 묶어 둡니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# index.py
import chromadb
from sentence_transformers import SentenceTransformer
from chunking import build_chunks

EMBED_MODEL = "jhgan/ko-sroberta-multitask"
COLLECTION = "policy"

def reindex(path: str = "./chroma") -> int:
    model = SentenceTransformer(EMBED_MODEL)
    client = chromadb.PersistentClient(path=path)
    collection = client.get_or_create_collection(
        name=COLLECTION, metadata={"hnsw:space": "cosine"}
    )

    chunks = build_chunks()
    texts = [c["text"] for c in chunks]
    collection.upsert(
        ids=[c["id"] for c in chunks],
        documents=texts,
        embeddings=model.encode(texts, normalize_embeddings=True).tolist(),
        metadatas=[{"source": c["source"], "title": c["title"]} for c in chunks],
    )

    stale = set(collection.get()["ids"]) - {c["id"] for c in chunks}   # 사라진 chunk 정리
    if stale:
        collection.delete(ids=list(stale))
    return collection.count()

if __name__ == "__main__":
    print(f"{reindex()}개 chunk 색인 완료")

마지막 정리 부분이 중요합니다. 문서가 짧아져서 chunk 수가 줄면 예전 id가 저장소에 남아 있습니다. 지우지 않으면 이미 삭제된 정책이 계속 검색되어 답에 섞입니다.

다른 선택지

저장소특징
Chroma로컬 파일 기반, 설치가 간단합니다. 프로토타입과 소규모에 적합합니다
FAISS라이브러리 형태로 매우 빠릅니다. 메타데이터 관리는 직접 해야 합니다
pgvectorPostgreSQL 확장입니다. 이미 쓰는 DB에 벡터 컬럼을 더하는 방식이라 운영 부담이 적습니다
Pinecone, Qdrant 등관리형 서비스입니다. 규모가 커지고 가용성이 필요할 때 검토합니다

인터페이스는 다르지만 하는 일은 같습니다. 벡터와 메타데이터를 넣고, 질의 벡터에 가까운 것을 k개 돌려주는 것입니다. 3편에서 numpy로 짜본 그 동작입니다.

다음 글: RAG 기초 (5) - Generation: 근거를 넣고 출처와 함께 답하기

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.