11. Document 추상화 모델

11.1. 여러 Document 모델을 둘려면?

PaikdabangMenuDocument 모델은 빽다방 메뉴 데이터를 저장하고 임베딩하는 모델입니다. 그런데, page_content, metadata, embedding 필드 등 여러 Document 모델에서 공통적으로 사용되는 필드로만 구성되어있습니다. 빽다방 관련된 부분은 모델명 밖에 없죠. 추가로 StarbucksMenuDocument 모델을 만들려면 PaikdabangMenuDocument 모델 코드를 복사&붙여넣기 하기보다, 장고의 추상화 모델 클래스를 상속받아 구현하시면 코드 중복없이 간결하게 여러 Document 모델을 정의하실 수 있게 됩니다.

기존 PaikdabangMenuDocument 클래스의 이름은 Document로 변경하고, PaikdabangMenuDocumentQuerySet 클래스의 이름은 DocumentQuerySet로 변경합니다. 그리고 Document 클래스는 Meta.abstract = True 설정으로 추상화 클래스로 선언하고 Meta.indexes 속성은 제거합니다. 인덱스 정책은 모델마다 달라질 수 있고, 각 인덱스 name은 유일한 이름으로 지정되어야 하기에 추상화 클래스에서는 지정할 수 없고, 상속받은 모델에서 인덱스를 직접 정의하도록 하겠습니다.

chat/models.py
 1# class PaikdabangMenuDocumentQuerySet(models.QuerySet):
 2class DocumentQuerySet(models.QuerySet):
 3    ...
 4
 5    async def search(self, question: str, k: int = 4) -> List["Document"]:
 6        ...
 7
 8# class PaikdabangMenuDocument(LifecycleModelMixin, models.Model):
 9class Document(LifecycleModelMixin, models.Model):
10    ...
11
12    class Meta:
13        abstract = True
14
15        # 인덱스는 추상화 클래스가 아닌 실제 모델에서 정의해야만 합니다.
16        # indexes = [
17        #     HnswIndex(
18        #         name="paikdabang_menu_doc_idx",
19        #         fields=["embedding"],
20        #         m=16,
21        #         ef_construction=64,
22        #         opclasses=["vector_cosine_ops"],
23        #     ),
24        # ]

이제 PaikdabangMenuDocument 모델과 StarbucksMenuDocument 모델은 Document 모델 상속 만으로 아래와 같이 손쉽게 정의할 수 있게 됩니다. PaikdabangMenuDocument 모델의 인덱스를 복사해서 인덱스명만 변경해주겠습니다.

chat/models.py
 1class PaikdabangMenuDocument(Document):
 2    class Meta:
 3        indexes = [
 4            HnswIndex(
 5                name="paikdabang_menu_doc_idx",
 6                fields=["embedding"],
 7                m=16,
 8                ef_construction=64,
 9                opclasses=["vector_cosine_ops"],
10            ),
11        ]
12
13class StarbucksMenuDocument(Document):
14    class Meta:
15        indexes = [
16            HnswIndex(
17                name="starbucks_menu_doc_idx",
18                fields=["embedding"],
19                m=16,
20                ef_construction=64,
21                opclasses=["vector_cosine_ops"],
22            ),
23        ]

마이그레이션을 해주시면 StarbucksMenuDocument 모델에 대한 테이블도 생성되고, DocumentQuerySet 클래스에서 정의한 search 메서드를 통해 질문과 유사한 문서 검색을 할 수 있게 됩니다.

11.2. 모델 인덱스에 맞춰 검색하기

인덱스를 정의할 때, 인덱스 생성 시에 사용할 벡터 연산 클래스를 지정합니다. pgvector 확장에서 지원하는 벡터 연산 목록은 장고 모델에 pgvector 적용 문서에 정리되어있습니다.

chat/models.py
 1class PaikdabangMenuDocument(Document):
 2    class Meta:
 3        indexes = [
 4            HnswIndex(
 5                name="paikdabang_menu_doc_idx",
 6                fields=["embedding"],
 7                m=16,
 8                ef_construction=64,
 9                opclasses=["vector_cosine_ops"],
10            ),
11        ]

코사인 거리 연산 클래스는 vector_cosine_ops이고 DocumentQuerySet에서 search 메서드에서는 인덱스를 활용할 수 있도록 CosineDistance 데이터베이스 함수를 통해 쿼리를 작성해야만 합니다.

인덱스 정의는 Document 모델 클래스에서 이뤄지고, 검색 쿼리는 DocumentQuerySet.search 메서드에서 이뤄집니다. search 메서드를 개선하여 Document 모델의 인덱스 선언에 맞춰 쿼리를 작성할 수 있도록 하겠습니다. pgvector 확장을 통해 여러 벡터 연산 클래스가 지원되지만, 본 튜토리얼에서는 코사인 거리와 L2 거리 연산 클래스만 구현했습니다.

chat/models.py
 1from django.core.exceptions import ImproperlyConfigured
 2from django.db.models import Index
 3from pgvector.django import CosineDistance, L2Distance
 4
 5class DocumentQuerySet(models.QuerySet):
 6    # ...
 7
 8    async def search(self, question: str, k: int = 4) -> List["Document"]:
 9        question_embedding: List[float] = await self.model.aembed(question)
10
11        qs = None
12        index: Index
13        for index in self.model._meta.indexes:
14            if "embedding" in index.fields:
15                if "vector_cosine_ops" in index.opclasses:
16                    qs = (qs or self).annotate(
17                        distance=CosineDistance("embedding", question_embedding)
18                    )
19                    qs = qs.order_by("distance")
20                elif "vector_l2_ops" in index.opclasses:
21                    qs = (qs or self).annotate(
22                        distance=L2Distance("embedding", question_embedding)
23                    )
24                    qs = qs.order_by("distance")
25                else:
26                    raise NotImplementedError(f"{index.opclasses}에 대한 검색 구현이 필요합니다.")
27
28        if qs is None:
29            raise ImproperlyConfigured(f"{self.model.__name__} 모델에 embedding 필드에 대한 인덱스를 추가해주세요.")
30
31        return await sync_to_async(list)(qs[:k])

11.3. make_vector_store 명령 개선

기존의 make_vector_store 명령은 PaikdabangMenuDocument 모델에 대한 벡터 저장소를 생성하는 명령이었습니다. 이제 Document 모델 상속 만으로 손쉽게 새로운 문서 모델을 만들 수 있으니, make_vector_store 명령도 다양한 문서 모델을 지원하도록 개선해보겠습니다.

  1. model 인자로 저장할 Document 모델 경로를 앱이름.모델명 포맷으로 지정합니다.

  2. get_model_class 메서드는 모델 경로를 받아 모델 클래스를 임포트하고, 모델 클래스의 유효성을 검증한 뒤에, 모델 클래스를 반환합니다.

  3. handle 메서드에서는 model 문자열 인자로 모델 클래스를 조회하고, 이를 활용합니다.

chat/management/commands/make_vector_store.py
 1import sys
 2from pathlib import Path
 3from typing import Type
 4
 5from django.core.management import BaseCommand
 6from django.db.models import Model
 7from django.utils.module_loading import import_string
 8from tqdm import tqdm
 9
10from chat import rag
11from chat.models import Document
12
13
14class Command(BaseCommand):
15    def add_arguments(self, parser):
16        parser.add_argument(
17            "model",
18            type=str,
19            help="저장할 Document 모델 경로 (예: 'chat.PaikdabangMenuDocument')",
20        )
21        parser.add_argument(
22            "txt_file_path",
23            type=str,
24            help="VectorStore로 저장할 원본 텍스트 파일 경로",
25        )
26
27    def print_error(self, msg: str) -> None:
28        self.stdout.write(self.style.ERROR(msg))
29        sys.exit(1)
30
31    def get_model_class(self, model_path: str) -> Type[Model]:
32        try:
33            module_name, class_name = model_path.rsplit(".", 1)
34            dotted_path = ".".join((module_name, "models", class_name))
35            ModelClass: Type[Model] = import_string(dotted_path)
36        except ImportError as e:
37            self.print_error(f"{model_path} 경로의 모델을 임포트할 수 없습니다. ({e})")
38
39        if not issubclass(ModelClass, Document):
40            self.print_error("Document 모델을 상속받은 모델이 아닙니다.")
41        elif ModelClass._meta.abstract:
42            self.print_error("추상화 모델은 사용할 수 없습니다.")
43
44        return ModelClass
45
46    def handle(self, *args, **options):
47        model_name = options["model"]
48        txt_file_path = Path(options["txt_file_path"])
49
50        ModelClass = self.get_model_class(model_name)
51
52        doc_list = rag.load(txt_file_path)
53        print(f"loaded {len(doc_list)} documents")
54        doc_list = rag.split(doc_list)
55        print(f"split into {len(doc_list)} documents")
56
57        new_doc_list = [
58            ModelClass(
59                page_content=doc.page_content,
60                metadata=doc.metadata,
61            )
62            for doc in tqdm(doc_list)
63        ]
64        ModelClass.objects.bulk_create(new_doc_list)

이제 make_vector_store 명령에서 지식 데이터 파일 경로와 함께 모델 클래스 경로를 지정하여 벡터 저장소에 지식을 저장할 수 있게 됩니다.

uv run python manage.py make_vector_store chat.PaikdabangMenuDocument  ./chat/assets/빽다방.txt

11.4. 이후 튜토리얼에서는

Document 모델마다 지식을 load/split하는 과정이 다를 텐데요. 이에 대해서는 이후 튜토리얼에서 다뤄보겠습니다.