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
은
유일한 이름으로 지정되어야 하기에 추상화 클래스에서는 지정할 수 없고,
상속받은 모델에서 인덱스를 직접 정의하도록 하겠습니다.
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
모델의 인덱스를 복사해서 인덱스명만 변경해주겠습니다.
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 적용 문서에 정리되어있습니다.
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 거리 연산 클래스만 구현했습니다.
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
명령도 다양한 문서 모델을 지원하도록 개선해보겠습니다.
model
인자로 저장할 Document 모델 경로를앱이름.모델명
포맷으로 지정합니다.get_model_class
메서드는 모델 경로를 받아 모델 클래스를 임포트하고, 모델 클래스의 유효성을 검증한 뒤에, 모델 클래스를 반환합니다.handle
메서드에서는model
문자열 인자로 모델 클래스를 조회하고, 이를 활용합니다.
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하는 과정이 다를 텐데요.
이에 대해서는 이후 튜토리얼에서 다뤄보겠습니다.