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 with polymorphic_on on the base class and polymorphic_identity on 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_RECORD entities.
  • Lines 5-6: Opens the ATTACHMENT entity definition and declares id as 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), and file_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) and processing_status (tracks the state of downstream processing like chunking/embedding).
  • Lines 14-15: Declares type_metadata as a JSONB column for flexible, type-specific metadata and created_at as a timestamp for record creation time.
  • Line 16: Closes the ATTACHMENT entity definition block.
  • Lines 17-19: Opens the EMBEDDING_RECORD entity definition, declares id as a UUID primary key, and attachment_id as a UUID foreign key linking back to the parent ATTACHMENT.
  • 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) and chunk_text_preview (a string preview of the chunk's text content).
  • Lines 23-24: Declares start_offset and end_offset as 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) and dimensions (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 status as an enum tracking the embedding lifecycle state and created_at as a timestamp for record creation.
  • Line 30: Closes the EMBEDDING_RECORD entity 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 — enum and uuid from the standard library, SQLAlchemy's ForeignKey, Index, and PostgreSQL-specific JSONB and UUID dialect types, plus the 2.0-style Mapped and mapped_column annotation helpers.
  • Lines 14-23: Define two enumerations. ContentType lists the five supported file categories that serve as polymorphic identities. UploadStatus tracks the lifecycle of the file transfer from client to cloud storage.
  • Lines 26-50: Declare the base Attachment model. The id column uses PostgreSQL's gen_random_uuid() server default so UUIDs are generated database-side. The message_id foreign key cascades deletes so removing a message removes all its attachments. The type_metadata JSONB column stores content-type-specific fields without adding nullable columns to the physical table.
  • Lines 52-55: The embeddings relationship uses cascade="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. The polymorphic_on directive points to the content_type column, and the base identity is None as a fallback for any unrecognized types.
  • Lines 62-64: A composite index on tenant_id and created_at accelerates the most common query pattern — listing a tenant's recent attachments.
  • Lines 67-77: PDFAttachment inherits the same physical table but sets its polymorphic identity to ContentType.PDF. The page_count property reads from and writes to type_metadata, providing a typed Python interface over the JSONB field. The setter initializes the dictionary if type_metadata is None.
  • Lines 80-87: ImageAttachment exposes a dimensions property that returns a tuple of width and height, or None if the metadata has not been populated yet.
  • Lines 81-86: URLAttachment surfaces the resolved_url after 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: EmbeddingStatus tracks the embedding lifecycle. The EXPIRED state 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_id foreign 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_id column avoids a join through attachments when 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_index provides deterministic ordering, while start_offset and end_offset record 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") and dimensions (e.g., 3072) let you query which records need re-embedding after a model upgrade. The vector_store_id is nullable because it is populated asynchronously after the embedding API call completes.
  • Lines 26-28: The status column defaults to PENDING, transitioning to EMBEDDED once the vector store confirms insertion, or FAILED if the embedding API returns an error.
  • Lines 29-30: token_count tracks per-chunk token usage for cost attribution, nullable because it may not be available from all embedding providers.
  • Lines 32: The attachment relationship 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-heavy async pipelines.

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_message retrieves all attachments regardless of content type. The selectinload strategy 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 with content_type = 'pdf' becomes a PDFAttachment instance with the page_count property available.
  • get_pdf_attachments_pending_processing queries the PDFAttachment subclass directly. SQLAlchemy automatically adds WHERE content_type = 'pdf' to the generated SQL, so the caller only receives PDFAttachment instances. The processing_status == "queued" filter identifies documents waiting in the ingestion pipeline.

Do's and Don'ts

Do's

  1. Do pair the ContentType enum discriminator with a type_metadata JSONB sidecar on the base attachments table — the enum column lets PostgreSQL filter and index by file type efficiently via __mapper_args__ polymorphic configuration, while type_metadata absorbs format-specific fields like PDF page counts, image dimensions, and audio duration without requiring an ALTER TABLE migration every time a new ContentType variant is added.
  2. Do record chunk_index, start_offset, and end_offset on every EMBEDDING_RECORD row — these byte-range fields are the only thing that lets retrieval code resolve a vector-store hit back to the exact passage in the source Attachment, 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.
  3. Do scope every EMBEDDING_RECORD to a tenant_id and record both embedding_model and dimensions on the same rowtenant_id gates multi-tenant vector search so one organization's embeddings never bleed into another's query results, and storing embedding_model alongside dimensions lets you run parallel searches across model upgrades without hitting dimension-mismatch errors in the vector store.

Don'ts

  1. Don't add per-format nullable columns (pdf_page_count, image_width, image_height) directly to the attachments table instead of routing them through type_metadata JSONB — every new ContentType variant 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.
  2. Don't collapse upload_status and processing_status into a single status field on Attachmentupload_status tracks whether the file reached object storage while processing_status tracks 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.
  3. Don't attach vector_store_id to the Attachment record instead of to each EMBEDDING_RECORD — a single attachment produces multiple chunks, each with its own entry in the external vector store; placing vector_store_id at the attachment level forces a one-to-many join table or nullable columns per chunk, and you lose the chunk_index plus 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

All free lessons in GenAI Application Engineering