Free lesson · GenAI Application Engineering
Build polymorphic file/attachment + embedding models
Build an Attachment SQLAlchemy model with id (UUID), message_id (ForeignKey), filename, content_type (Enum: pdf/docx/image/csv/url), file_size_bytes, storage_path (GCS URI), upload_status (pending/processing/ready/failed). Create DocumentChunk with id, attachment_id, chunk_index, content (Text), token_count, start_page, end_page, metadata_json (JSONB). Build EmbeddingRecord with id, chunk_id, embedding_model, vector (pgvector Vector(1536)), created_at. Add GIN index on metadata_json for JSONB queries. Implement get_chunks_by_source_type() filtering chunks by parent attachment content_type. Create Pydantic schemas AttachmentCreate, AttachmentResponse, ChunkResponse with model_validator for file size limits.
Course: Full-Stack GenAI Applications · Chapter 11 · Data Modeling for AI Applications
Free to read — no subscription required.
Introduction
When users drop a 40-page PDF, three images, and a CSV into a chat thread, your schema has to remember which message each one belongs to, what type it is, and where its hundreds of embedded chunks live in a vector store — without devolving into a wall of nullable columns or a fresh migration for every new file format. Teams that get this layer wrong end up writing one-off SQL scripts every time they upgrade an embedding model or scope vectors to a new tenant, and watching citations point at the wrong source passage when chunk provenance is lost. By the end of this lesson you'll be able to model file attachments using SQLAlchemy 2.0 single-table polymorphism and design an embedding-record schema that links source chunks back to attachments and forward to their external vector-store IDs.
Key Terminology
- Single-table polymorphism: A SQLAlchemy mapping where one physical table holds several related classes, distinguished by a discriminator column (
content_type) declared via__mapper_args__. Each subclass adds typed behavior without adding columns. - Polymorphic discriminator: The column whose value selects which subclass a row maps to — here
content_type— configured withpolymorphic_onon the baseclassandpolymorphic_identityon each subclass. - Embedding record: A metadata row that links a chunked text segment back to its source attachment and forward to the external vector-store entry, capturing chunk boundaries, the embedding model version, dimensionality, and tenant scope.
Concepts
Why polymorphic content types matter
A naive approach stores every attachment in one flat table with columns like pdf_page_count, image_width, image_height, csv_row_count, and url_redirect_target. Most columns are None for any given row, the table becomes difficult to validate, and adding a new content type means another migration that adds yet more nullable columns. Single-table inheritance solves this by mapping a discriminator column — here, content_type — to specialized Python classes that each declare only the metadata relevant to their file type. SQLAlchemy 2.0 implements this through the mapper_args dictionary with a polymorphic_on directive on the base class and polymorphic_identity values on each subclass.
The embedding record schema serves a different purpose. When a 40-page PDF is chunked into 200 text segments and each segment is embedded into a 1536-dimensional vector, you need to know: which attachment produced chunk 147, what byte range or page range it covers, which embedding model version generated the vector, and the external ID in your vector store (Pinecone, pgvector, Qdrant) so you can perform filtered similarity searches that respect tenant boundaries. This metadata lives in PostgreSQL even though the vectors themselves may live elsewhere.
Code Walkthrough
Entity relationship model
The following diagram illustrates how messages, attachments, and embedding records relate in a multi-tenant chat application. Each message can carry zero or more attachments, and each attachment can produce zero or more embedding records depending on how the content is chunked.
The ATTACHMENT and EMBEDDING_RECORD entities form a one-to-many pipeline where uploaded files flow through upload_status and processing_status state machines before producing chunked vector embeddings. Each EMBEDDING_RECORD tracks chunk_index, start_offset/end_offset byte ranges, and the embedding_model used, enabling multi-model vector search across tenants via tenant_id partitioning. The type_metadata JSONB column on ATTACHMENT stores format-specific attributes—image dimensions, audio duration, document page counts—without schema migration overhead, which matters when your chat application must handle arbitrary file types at ingest time.
Code snippet mermaid
Loading diagram...
- Line 1: Declares this as a Mermaid ER (Entity-Relationship) diagram definition.
- Line 2: Defines a one-to-many relationship where one CONVERSATION contains zero or more MESSAGE entities.
- Line 3: Defines a one-to-many relationship where one MESSAGE has zero or more ATTACHMENT entities.
- Line 4: Defines a one-to-many relationship where one ATTACHMENT produces zero or more
EMBEDDING_RECORDentities. - Lines 5-6: Opens the
ATTACHMENTentity definition and declaresidas a UUID primary key. - Line 7: Declares message_id as a UUID foreign key linking back to the parent MESSAGE.
- Lines 8-10: Defines file metadata fields:
filename(string name of the uploaded file),content_type(enum for MIME type), andfile_size_bytes(bigint storing the file size). - Line 11: Declares storage_path as a string holding the location of the file in object/blob storage.
- Lines 12-13: Defines two enum status-tracking fields:
upload_status(tracks whether the file upload succeeded) andprocessing_status(tracks the state of downstream processing like chunking/embedding). - Lines 14-15: Declares
type_metadataas a JSONB column for flexible, type-specific metadata andcreated_atas a timestamp for record creation time. - Line 16: Closes the ATTACHMENT entity definition block.
- Lines 17-19: Opens the
EMBEDDING_RECORDentity definition, declaresidas a UUID primary key, andattachment_idas a UUID foreign key linking back to the parentATTACHMENT. - Line 20: Declares tenant_id as a UUID identifying which tenant owns this embedding, enabling multi-tenant isolation.
- Lines 21-22: Defines
chunk_index(integer position of the chunk within the source document) andchunk_text_preview(a string preview of the chunk's text content). - Lines 23-24: Declares
start_offsetandend_offsetas integers marking the character boundaries of the chunk within the original attachment content. - Lines 25-26: Defines
embedding_model(string identifying which model generated the vector) anddimensions(integer specifying the dimensionality of the embedding vector). - Line 27: Declares vector_store_id as a string referencing the external vector store where the embedding is persisted.
- Lines 28-29: Defines
statusas an enum tracking the embedding lifecycle state andcreated_atas a timestamp for record creation. - Line 30: Closes the
EMBEDDING_RECORDentity definition block.
Notice that ATTACHMENT uses a content_type enum as the polymorphic discriminator and stores type-specific fields inside a type_metadata JSONB column. This hybrid approach — enum discriminator plus JSONB sidecar — gives you the query performance of a discriminator column with the flexibility of schemaless metadata per type.
Attachment model with single-table polymorphism
The following code defines the base Attachment model and three polymorphic subclasses: PDFAttachment, ImageAttachment, and URLAttachment. The base class uses SQLAlchemy 2.0's Mapped and mapped_column syntax to declare columns with full type annotation support. The ContentType enum serves as the polymorphic discriminator via the mapper_args configuration, while each subclass provides property accessors that read from and write to the shared type_metadata JSONB column, keeping the physical table clean while exposing typed Python attributes.
Code snippet python
1import enum 2import uuid 3from datetime import datetime 4from typing import Optional 5 6from sqlalchemy import ForeignKey, Index, text 7from sqlalchemy.dialects.postgresql import JSONB, UUID 8from sqlalchemy.orm import Mapped, mapped_column, relationship 9 10from app.db.base import Base 11 12class ContentType(enum.Enum): 13 PDF = "pdf" 14 DOCX = "docx" 15 IMAGE = "image" 16 CSV = "csv" 17 URL = "url" 18 19class UploadStatus(enum.Enum): 20 PENDING = "pending" 21 PROCESSING = "processing" 22 COMPLETED = "completed" 23 FAILED = "failed" 24 25class Attachment(Base): 26 __tablename__ = "attachments" 27 28 id: Mapped[uuid.UUID] = mapped_column( 29 UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()") 30 ) 31 message_id: Mapped[uuid.UUID] = mapped_column( 32 ForeignKey("messages.id", ondelete="CASCADE"), index=True 33 ) 34 tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True) 35 filename: Mapped[str] = mapped_column(nullable=False) 36 content_type: Mapped[ContentType] = mapped_column(nullable=False) 37 file_size_bytes: Mapped[int] = mapped_column(nullable=False) 38 storage_path: Mapped[str] = mapped_column(nullable=False) 39 upload_status: Mapped[UploadStatus] = mapped_column( 40 default=UploadStatus.PENDING 41 ) 42 processing_status: Mapped[str] = mapped_column(default="queued") 43 type_metadata: Mapped[Optional[dict]] = mapped_column(JSONB, default=dict) 44 created_at: Mapped[datetime] = mapped_column(server_default=text("now()")) 45 46 embeddings: Mapped[list["EmbeddingRecord"]] = relationship( 47 back_populates="attachment", cascade="all, delete-orphan" 48 ) 49 50 __mapper_args__ = { 51 "polymorphic_on": "content_type", 52 "polymorphic_identity": None, 53 } 54 __table_args__ = ( 55 Index("ix_attach_tenant_created", "tenant_id", "created_at"), 56 ) 57 58class PDFAttachment(Attachment): 59 __mapper_args__ = {"polymorphic_identity": ContentType.PDF} 60 61 @property 62 def page_count(self) -> Optional[int]: 63 return (self.type_metadata or {}).get("page_count") 64 65 @page_count.setter 66 def page_count(self, value: int) -> None: 67 if self.type_metadata is None: 68 self.type_metadata = {} 69 self.type_metadata["page_count"] = value 70 71class ImageAttachment(Attachment): 72 __mapper_args__ = {"polymorphic_identity": ContentType.IMAGE} 73 74 @property 75 def dimensions(self) -> Optional[tuple[int, int]]: 76 meta = self.type_metadata or {} 77 if "width" in meta and "height" in meta: 78 return (meta["width"], meta["height"]) 79 return None 80 81class URLAttachment(Attachment): 82 __mapper_args__ = {"polymorphic_identity": ContentType.URL} 83 84 @property 85 def resolved_url(self) -> Optional[str]: 86 return (self.type_metadata or {}).get("resolved_url")
- Lines 1-11: Import foundation modules —
enumanduuidfrom the standard library, SQLAlchemy'sForeignKey,Index, and PostgreSQL-specificJSONBandUUIDdialect types, plus the 2.0-styleMappedandmapped_columnannotation helpers. - Lines 14-23: Define two enumerations.
ContentTypelists the five supported file categories that serve as polymorphic identities.UploadStatustracks the lifecycle of the file transfer from client to cloud storage. - Lines 26-50: Declare the base
Attachmentmodel. Theidcolumn uses PostgreSQL'sgen_random_uuid()server default so UUIDs are generated database-side. Themessage_idforeign key cascades deletes so removing a message removes all its attachments. Thetype_metadataJSONB column stores content-type-specific fields without adding nullable columns to the physical table. - Lines 52-55: The
embeddingsrelationship usescascade="all, delete-orphan"so that deleting an attachment also removes its embedding records, keeping the vector store reference table consistent. - Lines 57-61:
__mapper_args__configures single-table polymorphism. Thepolymorphic_ondirective points to thecontent_typecolumn, and the base identity is None as a fallback for any unrecognized types. - Lines 62-64: A composite index on
tenant_idandcreated_ataccelerates the most common query pattern — listing a tenant's recent attachments. - Lines 67-77:
PDFAttachmentinherits the same physical table but sets its polymorphic identity toContentType.PDF. Thepage_countproperty reads from and writes totype_metadata, providing a typed Python interface over the JSONB field. The setter initializes the dictionary iftype_metadatais None. - Lines 80-87:
ImageAttachmentexposes adimensionsproperty that returns a tuple of width and height, or None if the metadata has not been populated yet. - Lines 81-86:
URLAttachmentsurfaces theresolved_urlafter redirect resolution, useful for link-preview generation and deduplication.
Embedding record schema
When an attachment is processed — a PDF parsed into chunks, an image captioned, a URL scraped — each resulting text segment needs a metadata row that links back to the source attachment and forward to the vector store entry. The following EmbeddingRecord model captures chunk boundaries, the embedding model version, vector dimensionality, and the external vector store identifier. This design lets you re-embed selectively when you upgrade models (filter by embedding_model), purge vectors for deleted tenants (filter by tenant_id), and reconstruct chunk provenance for citation generation (join back to Attachment).
Code snippet python
1class EmbeddingStatus(enum.Enum): 2 PENDING = "pending" 3 EMBEDDED = "embedded" 4 FAILED = "failed" 5 EXPIRED = "expired" 6 7class EmbeddingRecord(Base): 8 __tablename__ = "embedding_records" 9 10 id: Mapped[uuid.UUID] = mapped_column( 11 UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()") 12 ) 13 attachment_id: Mapped[uuid.UUID] = mapped_column( 14 ForeignKey("attachments.id", ondelete="CASCADE"), index=True 15 ) 16 tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True) 17 chunk_index: Mapped[int] = mapped_column(nullable=False) 18 chunk_text_preview: Mapped[str] = mapped_column(nullable=False) 19 start_offset: Mapped[int] = mapped_column(nullable=False) 20 end_offset: Mapped[int] = mapped_column(nullable=False) 21 embedding_model: Mapped[str] = mapped_column(nullable=False) 22 dimensions: Mapped[int] = mapped_column(nullable=False) 23 vector_store_id: Mapped[Optional[str]] = mapped_column(nullable=True) 24 status: Mapped[EmbeddingStatus] = mapped_column( 25 default=EmbeddingStatus.PENDING 26 ) 27 token_count: Mapped[Optional[int]] = mapped_column(nullable=True) 28 created_at: Mapped[datetime] = mapped_column(server_default=text("now()")) 29 30 attachment: Mapped["Attachment"] = relationship(back_populates="embeddings") 31 32 __table_args__ = ( 33 Index("ix_embed_tenant_model", "tenant_id", "embedding_model"), 34 Index("ix_embed_attachment_chunk", "attachment_id", "chunk_index", unique=True), 35 ) 36 37async def get_attachments_for_message( 38 session: AsyncSession, message_id: uuid.UUID 39) -> list[Attachment]: 40 stmt = ( 41 select(Attachment) 42 .where(Attachment.message_id == message_id) 43 .options(selectinload(Attachment.embeddings)) 44 .order_by(Attachment.created_at) 45 ) 46 result = await session.execute(stmt) 47 return list(result.scalars().all()) 48 49async def get_pdf_attachments_pending_processing( 50 session: AsyncSession, tenant_id: uuid.UUID 51) -> list[PDFAttachment]: 52 stmt = ( 53 select(PDFAttachment) 54 .where( 55 PDFAttachment.tenant_id == tenant_id, 56 PDFAttachment.processing_status == "queued", 57 ) 58 .order_by(PDFAttachment.created_at) 59 ) 60 result = await session.execute(stmt) 61 return list(result.scalars().all())
- Lines 1-6:
EmbeddingStatustracks the embedding lifecycle. TheEXPIREDstate marks records whose vectors were generated by a deprecated model version and need re-embedding. - Lines 9-12: The primary key follows the same UUID pattern as
Attachment, ensuring globally unique identifiers across a distributed system. - Lines 14-16: The
attachment_idforeign key cascades deletes, so purging an attachment also removes all its embedding metadata. The index accelerates chunk lookups for a specific attachment. - Lines 17-18: A denormalized
tenant_idcolumn avoids a join throughattachmentswhen filtering embeddings by tenant — a critical optimization for vector search pre-filtering where you need to pass tenant-scoped IDs to the vector store. - Lines 19-22:
chunk_indexprovides deterministic ordering, whilestart_offsetandend_offsetrecord byte positions in the original extracted text so you can highlight the exact source passage in citations. - Lines 23-25:
embedding_model(e.g.,"text-embedding-3-large") anddimensions(e.g.,3072) let you query which records need re-embedding after a model upgrade. Thevector_store_idis nullable because it is populated asynchronously after the embedding API call completes. - Lines 26-28: The
statuscolumn defaults toPENDING, transitioning toEMBEDDEDonce the vector store confirms insertion, orFAILEDif the embedding API returns an error. - Lines 29-30:
token_counttracks per-chunk token usage for cost attribution, nullable because it may not be available from all embedding providers. - Lines 32: The
attachmentrelationship provides the reverse navigation path for ORM-level joins and eager loading. - Lines 34-37: Two composite indexes optimize the dominant access patterns. The
(tenant_id, embedding_model)index powers re-embedding queries that target a specific model version within a tenant. The(attachment_id, chunk_index)unique index enforces that no attachment can have duplicate chunk numbers, preventing double-insertion bugs in retry-heavyasyncpipelines.
Querying polymorphic attachments
A key advantage of single-table polymorphism is transparent querying. When you query the base Attachment class, SQLAlchemy returns the correct subclass instance based on the discriminator. Querying PDFAttachment directly adds an implicit WHERE content_type = 'pdf' filter, as shown by the two repository helpers appended to the code block above.
get_attachments_for_messageretrieves all attachments regardless of content type. Theselectinloadstrategy eagerly loads embedding records in a single additional query, avoiding N+1 problems when the caller iterates over attachments and accesses their embeddings. Each returned object is automatically instantiated as the correct subclass — a row withcontent_type = 'pdf'becomes aPDFAttachmentinstance with thepage_countproperty available.get_pdf_attachments_pending_processingqueries thePDFAttachmentsubclass directly. SQLAlchemy automatically addsWHERE content_type = 'pdf'to the generated SQL, so the caller only receivesPDFAttachmentinstances. Theprocessing_status == "queued"filter identifies documents waiting in the ingestion pipeline.
Do's and Don'ts
Do's
- ✓Do pair the
ContentTypeenum discriminator with atype_metadataJSONB sidecar on the baseattachmentstable — the enum column lets PostgreSQL filter and index by file type efficiently via__mapper_args__polymorphic configuration, whiletype_metadataabsorbs format-specific fields like PDF page counts, image dimensions, and audio duration without requiring anALTER TABLEmigration every time a newContentTypevariant is added. - ✓Do record
chunk_index,start_offset, andend_offseton everyEMBEDDING_RECORDrow — these byte-range fields are the only thing that lets retrieval code resolve a vector-store hit back to the exact passage in the sourceAttachment, so citations in the chat UI point at the correct source chunk rather than the wrong one when multiple chunks from the same file are returned. - ✓Do scope every
EMBEDDING_RECORDto atenant_idand record bothembedding_modelanddimensionson the same row —tenant_idgates multi-tenant vector search so one organization's embeddings never bleed into another's query results, and storingembedding_modelalongsidedimensionslets you run parallel searches across model upgrades without hitting dimension-mismatch errors in the vector store.
Don'ts
- ✗Don't add per-format nullable columns (
pdf_page_count,image_width,image_height) directly to theattachmentstable instead of routing them throughtype_metadataJSONB — every newContentTypevariant forces a schema migration and leaves the table littered with sparse nullable columns, which is exactly the problem the hybrid enum-plus-JSONB approach is designed to eliminate. - ✗Don't collapse
upload_statusandprocessing_statusinto a single status field onAttachment—upload_statustracks whether the file reached object storage whileprocessing_statustracks whether the chunking and embedding pipeline succeeded; merging them hides failures where a file uploaded cleanly but the embedding step crashed, making it impossible to retry only the processing stage without re-uploading. - ✗Don't attach
vector_store_idto theAttachmentrecord instead of to eachEMBEDDING_RECORD— a single attachment produces multiple chunks, each with its own entry in the external vector store; placingvector_store_idat the attachment level forces a one-to-many join table or nullable columns per chunk, and you lose thechunk_indexplus byte-range provenance that ties each vector hit back to its precise source passage.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in Full-Stack GenAI Applications
- Ch 9Build an event broadcast system with Redis pub/sub
- Ch 10Build JWT auth with refresh-token rotation and Redis sessions
- Ch 10Build conversation ownership + RBAC sharing
- Ch 10Build Llama Guard 4 content classifier
- Ch 11Build polymorphic file/attachment + embedding modelsYou are here
- Ch 12Build Redis-backed token-bucket rate limiter
- Ch 12Build gateway-level guardrails with audit logging