Introduction
The previous article defined the high-level system architecture for the AI Tender Opportunity Scanner.
We established that the product will use:
- React for the frontend
- FastAPI for the backend API
- PostgreSQL for persistent storage
- SQLAlchemy for database access
- Alembic for schema migrations
- pgvector for semantic matching
- OpenAI services for summaries and embeddings
The next step is designing the database schema that connects these components.
The database will become the operational foundation of the application. It must store tender notices, preserve raw procurement data, track ingestion runs, hold AI-generated summaries, maintain business profiles, calculate user-specific matches, and record actions such as saving or dismissing an opportunity.
A weak schema can create serious problems later:
- Duplicate tenders
- Inconsistent source data
- Difficult migrations
- Slow dashboard queries
- Unclear data ownership
- Untraceable AI output
- Expensive reprocessing
- Broken user isolation
In this article, we will design a practical relational schema for the MVP while preserving a clear path toward authentication, subscriptions, multiple tender sources, and team accounts.
Development Progress
Project Scope and Roadmap ████████████████████ 100%MVP Definition ████████████████████ 100%Technology Stack ████████████████████ 100%System Architecture ████████████████████ 100%Database Design ████████████████████ 100%Backend Foundation ░░░░░░░░░░░░░░░░░░░░ 0%Tender Ingestion ░░░░░░░░░░░░░░░░░░░░ 0%AI Analysis ░░░░░░░░░░░░░░░░░░░░ 0%Matching Engine ░░░░░░░░░░░░░░░░░░░░ 0%Frontend Dashboard ░░░░░░░░░░░░░░░░░░░░ 0%
Database Design Goals
Before defining tables, we need to establish the main design principles.
1. Separate Shared Data from User-Owned Data
Tender notices are global.
A tender published by a procurement authority should be stored once and reused across all users.
Business profiles, match scores, saved tenders, dismissals, and notes are user-specific.
This distinction prevents the same tender from being duplicated for every account.
2. Preserve Raw Source Payloads
Normalized tender fields will power the dashboard and matching engine.
However, normalization may discard source-specific fields or interpret values incorrectly.
The original source payload must therefore be retained for:
- Auditing
- Reprocessing
- Debugging
- Source-format changes
- New field extraction
- Compliance investigations
3. Make Processing Traceable
The database should make it possible to determine:
- When a tender was collected
- Which source produced it
- Which ingestion run imported it
- Which model summarized it
- Which embedding model created its vector
- Which scoring version generated a match
- Why a processing attempt failed
This traceability is essential for a data and AI product.
4. Enforce Data Integrity
The database should prevent invalid states where possible.
Examples include:
- Duplicate source records
- Match scores outside the valid range
- Saved tenders without users
- Matches without profiles
- Tender summaries without tenders
- Negative contract values
- Invalid date relationships
Application validation is useful, but database constraints provide the final protection.
5. Support Reprocessing
A tender may need to be:
- Renormalized
- Resummarized
- Re-embedded
- Rematched
- Updated after a source change
The schema should allow current results to be replaced or versioned without recreating the tender.
6. Optimize for MVP Queries
The initial dashboard will frequently query:
- Tenders ordered by match score
- Active tenders before the deadline
- Tenders within a country
- Tenders within a value range
- Saved tenders
- Dismissed tenders
- New recommendations
- Failed processing records
Indexes should support these workflows.
Core Entity Overview
The initial schema will contain the following major entities:
UserBusinessProfileTenderSourceIngestionRunRawTenderRecordTenderTenderSummaryTenderEmbeddingBusinessProfileEmbeddingTenderMatchSavedTenderDismissedTenderProcessingError
A later version may add:
OrganizationOrganizationMemberSubscriptionAlertPreferenceAlertDeliveryTenderDocumentTenderStatusHistoryAuditLog
These later entities do not need to block the MVP.
High-Level Relationship Diagram
User | | one-to-one or one-to-many vBusinessProfile | | one-to-many vTenderMatch ----------------------+ | | | many-to-one | many-to-one v vTender BusinessProfile | +-------------------+---------------------+ | | | v v vTenderSummary TenderEmbedding SavedTender | vDismissedTenderTenderSource | | one-to-many vRawTenderRecord | | many-to-one vTenderTenderSource | | one-to-many vIngestionRun | | one-to-many vRawTenderRecordTender | | one-to-many vProcessingError
This diagram is simplified.
Some relationships, such as saved and dismissed records, also reference users directly.
Entity 1: Users
The users table represents application accounts.
Authentication may be introduced after the first local prototype, but including a user entity now prevents user ownership from being retrofitted later.
Proposed Fields
idemailhashed_passwordfull_nameis_activeis_admincreated_atupdated_at
Example Structure
users------------------------------------------id UUID primary keyemail VARCHAR unique not nullhashed_password VARCHAR nullable initiallyfull_name VARCHAR nullableis_active BOOLEAN default trueis_admin BOOLEAN default falsecreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
The hashed_password field may remain nullable during early development if the application uses a seeded local user.
Before public deployment, authentication requirements will be tightened.
Important Constraints
- Email must be unique.
- Email should be normalized before storage.
- Passwords must never be stored as plain text.
- User deletion must be handled carefully because user-owned records may need retention or anonymization.
Entity 2: Business Profiles
The business_profiles table stores the information used to match tenders to a business.
The MVP will initially support one active profile per user.
The schema can still use a one-to-many relationship so that multiple profiles can be introduced later.
Proposed Fields
iduser_idnamecompany_namecompany_descriptionservicestarget_industriespreferred_countriespreferred_regionsincluded_keywordsexcluded_keywordsminimum_contract_valuemaximum_contract_valuepreferred_currencycertificationspreferred_languagesis_activecreated_atupdated_at
Example Structure
business_profiles------------------------------------------------id UUID primary keyuser_id UUID foreign keyname VARCHAR not nullcompany_name VARCHAR nullablecompany_description TEXT not nullservices JSONB not nulltarget_industries JSONB nullablepreferred_countries JSONB nullablepreferred_regions JSONB nullableincluded_keywords JSONB nullableexcluded_keywords JSONB nullableminimum_contract_value NUMERIC nullablemaximum_contract_value NUMERIC nullablepreferred_currency CHAR(3) nullablecertifications JSONB nullablepreferred_languages JSONB nullableis_active BOOLEAN default truecreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
Why Use JSONB for Lists?
Fields such as services, industries, keywords, and certifications are naturally list-based.
For the MVP, JSONB provides a practical balance between structure and flexibility.
Example:
[ "cloud migration", "Microsoft 365", "managed IT services", "cybersecurity consulting"]
A later version could normalize some of these values into separate tables if advanced analytics, taxonomies, or shared classification systems become important.
Business Profile Value Constraints
The following validation rules should apply:
minimum_contract_value >= 0maximum_contract_value >= 0minimum_contract_value <= maximum_contract_value
These rules should be enforced both in the API and through database constraints where practical.
Entity 3: Tender Sources
The tender_sources table describes procurement platforms, APIs, feeds, or portals.
Examples might include:
- A European tender feed
- A national procurement API
- A regional government portal
- A manually imported dataset
Proposed Fields
idnameslugsource_typebase_urlcountry_codeis_activeconfigurationcreated_atupdated_at
Example Structure
tender_sources-----------------------------------------------id UUID primary keyname VARCHAR not nullslug VARCHAR unique not nullsource_type VARCHAR not nullbase_url TEXT nullablecountry_code CHAR(2) nullableis_active BOOLEAN default trueconfiguration JSONB nullablecreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
Source Type Examples
apijson_feedxml_feedcsv_importhtmlmanual
The configuration field can store non-secret source settings such as:
{ "page_size": 100, "default_language": "en", "supports_updates": true}
Secret credentials should remain in environment variables or a secret manager rather than inside this table.
Entity 4: Ingestion Runs
The ingestion_runs table records each execution of a tender import.
This provides operational visibility and allows developers or administrators to inspect historical ingestion behavior.
Proposed Fields
idsource_idstatustrigger_typestarted_atcompleted_atrecords_retrievedrecords_createdrecords_updatedrecords_skippedrecords_failedcursor_valueerror_summarycreated_at
Example Structure
ingestion_runs------------------------------------------------id UUID primary keysource_id UUID foreign keystatus VARCHAR not nulltrigger_type VARCHAR not nullstarted_at TIMESTAMP WITH TIME ZONEcompleted_at TIMESTAMP WITH TIME ZONE nullablerecords_retrieved INTEGER default 0records_created INTEGER default 0records_updated INTEGER default 0records_skipped INTEGER default 0records_failed INTEGER default 0cursor_value TEXT nullableerror_summary TEXT nullablecreated_at TIMESTAMP WITH TIME ZONE
Ingestion Status Values
pendingrunningcompletedcompleted_with_errorsfailedcancelled
Trigger Type Values
manualscheduledretrybackfill
Why Store a Cursor?
Some procurement APIs support incremental collection through:
- Last-modified timestamps
- Pagination tokens
- Publication dates
- Sequence numbers
The cursor_value field allows the ingestion process to resume or continue from a known point.
Entity 5: Raw Tender Records
The raw_tender_records table preserves the original data received from a source.
A raw record represents the source-level notice before normalization.
Proposed Fields
idsource_idingestion_run_idexternal_idsource_urlpayloadpayload_hashretrieved_atprocessing_statusprocessing_attemptslast_errorcreated_atupdated_at
Example Structure
raw_tender_records------------------------------------------------id UUID primary keysource_id UUID foreign keyingestion_run_id UUID foreign key nullableexternal_id VARCHAR not nullsource_url TEXT nullablepayload JSONB not nullpayload_hash VARCHAR nullableretrieved_at TIMESTAMP WITH TIME ZONEprocessing_status VARCHAR not nullprocessing_attempts INTEGER default 0last_error TEXT nullablecreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
Unique Constraint
The first important uniqueness rule is:
UNIQUE(source_id, external_id)
This prevents the same source record from being imported repeatedly as a new row.
Payload Hash
A checksum of the raw payload can help detect whether an existing source record has changed.
For example:
Existing external ID + same hash:No material update.Existing external ID + different hash:Source record has changed and may require reprocessing.
Raw Processing Status Values
retrievednormalization_pendingnormalizedfailedignored
Entity 6: Tenders
The tenders table stores normalized tender data.
This is the primary business entity used by the API, dashboard, AI analysis, and matching engine.
Proposed Fields
idsource_idraw_record_idexternal_idreference_numbertitledescriptioncontracting_authoritybuyer_typecountry_coderegioncitypublication_datesubmission_deadlinecontract_start_datecontract_end_dateestimated_valuecurrencyprocurement_categorycpv_codeslanguage_codesource_urlnotice_statusprocessing_statuscreated_atupdated_at
Example Structure
tenders------------------------------------------------id UUID primary keysource_id UUID foreign keyraw_record_id UUID foreign key nullableexternal_id VARCHAR not nullreference_number VARCHAR nullabletitle TEXT not nulldescription TEXT nullablecontracting_authority TEXT nullablebuyer_type VARCHAR nullablecountry_code CHAR(2) nullableregion VARCHAR nullablecity VARCHAR nullablepublication_date DATE nullablesubmission_deadline TIMESTAMP WITH TIME ZONE nullablecontract_start_date DATE nullablecontract_end_date DATE nullableestimated_value NUMERIC nullablecurrency CHAR(3) nullableprocurement_category VARCHAR nullablecpv_codes JSONB nullablelanguage_code VARCHAR nullablesource_url TEXT nullablenotice_status VARCHAR not nullprocessing_status VARCHAR not nullcreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
Tender Uniqueness
The normalized tender should also enforce:
UNIQUE(source_id, external_id)
This provides a second level of duplicate protection.
Notice Status Values
activeexpiredawardedcancelledwithdrawnunknown
Processing Status Values
normalization_completeanalysis_pendinganalysis_completeembedding_pendingembedding_completematching_pendingprocessedfailed
The status list may be simplified later.
It is often better to use separate status fields for separate pipelines if one general status becomes ambiguous.
Contract Value Type
Contract values should use a decimal-compatible database type such as NUMERIC.
Floating-point values should not be used for monetary amounts.
Example:
NUMERIC(18, 2)
This supports large contracts while preserving currency precision.
Optional Fields
Tender sources frequently omit values such as contract amount, region, contract dates, or category.
These columns should remain nullable.
Missing data should be represented as unknown rather than replaced with misleading defaults.
Entity 7: Tender Summaries
The tender_summaries table stores AI-generated structured analysis.
Keeping AI output separate from the tender table has several advantages:
- AI results can be regenerated
- Prompt versions can be tracked
- Model versions can be compared
- Failures can be isolated
- Tender records remain source-focused
Proposed Fields
idtender_idoverviewdeliverablesrequirementscertificationslanguage_requirementsriskscontract_durationmissing_informationmodel_nameprompt_versioninput_hashtoken_inputtoken_outputstatusgenerated_atcreated_atupdated_at
Example Structure
tender_summaries------------------------------------------------id UUID primary keytender_id UUID foreign keyoverview TEXT nullabledeliverables JSONB nullablerequirements JSONB nullablecertifications JSONB nullablelanguage_requirements JSONB nullablerisks JSONB nullablecontract_duration VARCHAR nullablemissing_information JSONB nullablemodel_name VARCHAR not nullprompt_version VARCHAR not nullinput_hash VARCHAR nullabletoken_input INTEGER nullabletoken_output INTEGER nullablestatus VARCHAR not nullgenerated_at TIMESTAMP WITH TIME ZONE nullablecreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
One Current Summary or Multiple Versions?
Two approaches are possible.
Option A: One Summary per Tender
Enforce:
UNIQUE(tender_id)
When a summary is regenerated, update the existing row.
This is simpler for the MVP.
Option B: Versioned Summaries
Allow multiple summaries for the same tender and mark one as current.
This provides a complete historical record but creates more complexity.
For the initial build, one current summary per tender is sufficient.
Version history can later be added through an audit or revision table.
Input Hash
An input_hash can represent the tender text used to generate the summary.
If the tender content has not changed, the system can avoid regenerating the summary unnecessarily.
Entity 8: Tender Embeddings
The tender_embeddings table stores the vector representation of each tender.
Proposed Fields
idtender_idembeddingmodel_nameembedding_dimensionscontent_hashcreated_atupdated_at
Example Structure
tender_embeddings------------------------------------------------id UUID primary keytender_id UUID foreign key uniqueembedding VECTOR not nullmodel_name VARCHAR not nullembedding_dimensions INTEGER not nullcontent_hash VARCHAR nullablecreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
The exact vector dimension depends on the selected embedding model.
Why a Separate Table?
Embedding data can be stored directly on the tender table, but a separate table provides:
- Cleaner tender records
- Easier model migration
- Optional embedding generation
- Better isolation of vector-specific indexes
- Easier replacement when models change
Entity 9: Business Profile Embeddings
The business_profile_embeddings table stores semantic vectors for user profiles.
Proposed Fields
idbusiness_profile_idembeddingmodel_nameembedding_dimensionscontent_hashcreated_atupdated_at
Example Structure
business_profile_embeddings------------------------------------------------id UUID primary keybusiness_profile_id UUID foreign key uniqueembedding VECTOR not nullmodel_name VARCHAR not nullembedding_dimensions INTEGER not nullcontent_hash VARCHAR nullablecreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
The embedding should be regenerated whenever the profile’s matching content changes.
Entity 10: Tender Matches
The tender_matches table connects a business profile to a tender.
This is one of the most important tables in the application.
A tender is global, but its relevance is profile-specific.
Proposed Fields
idbusiness_profile_idtender_idoverall_scoresemantic_scorekeyword_scoreservice_scoreindustry_scoregeography_scorevalue_scorecertification_scoredeadline_scoreexclusion_penaltystrong_matchespotential_gapsscore_versionstatuscalculated_atcreated_atupdated_at
Example Structure
tender_matches------------------------------------------------id UUID primary keybusiness_profile_id UUID foreign keytender_id UUID foreign keyoverall_score NUMERIC not nullsemantic_score NUMERIC nullablekeyword_score NUMERIC nullableservice_score NUMERIC nullableindustry_score NUMERIC nullablegeography_score NUMERIC nullablevalue_score NUMERIC nullablecertification_score NUMERIC nullabledeadline_score NUMERIC nullableexclusion_penalty NUMERIC default 0strong_matches JSONB nullablepotential_gaps JSONB nullablescore_version VARCHAR not nullstatus VARCHAR not nullcalculated_at TIMESTAMP WITH TIME ZONEcreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
Unique Constraint
A profile should have one current match record per tender:
UNIQUE(business_profile_id, tender_id)
If the match is recalculated, the record can be updated.
Score Constraints
The database should enforce valid score ranges.
For example:
overall_score >= 0overall_score <= 100
The same rule can apply to individual components if they use a zero-to-one-hundred scale.
Alternatively, component scores may use zero-to-one internally.
The selected convention must remain consistent.
Why Store Score Components?
Only storing the final score would make debugging difficult.
Separate components allow us to answer:
- Was the semantic score too high?
- Did the geography rule fail?
- Was the tender rejected due to value?
- Did an exclusion keyword reduce the score?
- Which scoring version produced the result?
This is critical for match-quality improvement.
Match Status Values
newviewedsaveddismissedexpired
There is an important design question here.
Should status live in tender_matches, or should save and dismiss actions be stored separately?
For the MVP, saved and dismissed actions will remain separate tables because they represent explicit user actions and may require metadata.
The match status can therefore focus on system-level state such as:
activebelow_thresholdexpiredfailed
Entity 11: Saved Tenders
The saved_tenders table records tenders that a user wants to review further.
Proposed Fields
iduser_idtender_idbusiness_profile_idnotesaved_atcreated_atupdated_at
Example Structure
saved_tenders------------------------------------------------id UUID primary keyuser_id UUID foreign keytender_id UUID foreign keybusiness_profile_id UUID foreign key nullablenote TEXT nullablesaved_at TIMESTAMP WITH TIME ZONEcreated_at TIMESTAMP WITH TIME ZONEupdated_at TIMESTAMP WITH TIME ZONE
Unique Constraint
A user should not save the same tender repeatedly:
UNIQUE(user_id, tender_id)
The business_profile_id field can preserve the profile context under which the tender was saved.
Entity 12: Dismissed Tenders
The dismissed_tenders table stores opportunities that a user does not want to see in normal recommendations.
Proposed Fields
iduser_idtender_idbusiness_profile_idreason_codereason_textdismissed_atcreated_at
Example Structure
dismissed_tenders------------------------------------------------id UUID primary keyuser_id UUID foreign keytender_id UUID foreign keybusiness_profile_id UUID foreign key nullablereason_code VARCHAR nullablereason_text TEXT nullabledismissed_at TIMESTAMP WITH TIME ZONEcreated_at TIMESTAMP WITH TIME ZONE
Example Reason Codes
not_relevantwrong_countrycontract_too_largecontract_too_smallmissing_certificationdeadline_too_closewrong_industryalready_knownother
These reasons may later be used to improve the matching engine.
Unique Constraint
UNIQUE(user_id, tender_id)
A user should have only one current dismissal record for a tender.
Removing a dismissal can delete the row.
Entity 13: Processing Errors
The processing_errors table stores failures from ingestion, normalization, AI analysis, embeddings, and matching.
Proposed Fields
idstagesource_idingestion_run_idraw_tender_record_idtender_idbusiness_profile_iderror_typeerror_messageerror_detailsretryableretry_countresolvedoccurred_atresolved_atcreated_at
Example Structure
processing_errors------------------------------------------------id UUID primary keystage VARCHAR not nullsource_id UUID foreign key nullableingestion_run_id UUID foreign key nullableraw_tender_record_id UUID foreign key nullabletender_id UUID foreign key nullablebusiness_profile_id UUID foreign key nullableerror_type VARCHAR not nullerror_message TEXT not nullerror_details JSONB nullableretryable BOOLEAN default falseretry_count INTEGER default 0resolved BOOLEAN default falseoccurred_at TIMESTAMP WITH TIME ZONEresolved_at TIMESTAMP WITH TIME ZONE nullablecreated_at TIMESTAMP WITH TIME ZONE
Processing Stage Values
source_fetchraw_storagenormalizationai_summaryembeddingmatchingnotification
Why Store Errors in the Database?
Application logs are useful but may be temporary or difficult to query.
Database error records allow administrators to:
- Filter unresolved failures
- Retry specific tenders
- Inspect error patterns
- Identify problematic sources
- Track repeated AI failures
- Measure processing reliability
Logs and database error records serve different purposes and should complement one another.
Optional Entity: Tender Documents
Tender documents are excluded from the first strict MVP, but the schema may later include a tender_documents table.
Possible Fields
idtender_idexternal_document_idfilenamedocument_typesource_urlstorage_pathmime_typefile_sizechecksumdownload_statusextraction_statuscreated_atupdated_at
This table should not be implemented until document downloading and analysis become part of the product scope.
Optional Entity: Alert Preferences
Daily email alerts are also postponed, but the later schema may include:
alert_preferences--------------------------------iduser_idbusiness_profile_idfrequencyminimum_scorepreferred_timetimezoneis_enabledcreated_atupdated_at
This supports daily or weekly tender digests.
Shared Data Versus User-Specific Data
The distinction between global and user-owned tables should now be explicit.
Shared Tables
tender_sourcesingestion_runsraw_tender_recordstenderstender_summariestender_embeddingsprocessing_errors
These records describe procurement data and system processing.
User-Specific Tables
usersbusiness_profilesbusiness_profile_embeddingstender_matchessaved_tendersdismissed_tenders
These records describe how an individual user or business relates to global tender data.
Recommended Foreign-Key Relationships
User Relationships
users.id -> business_profiles.user_id -> saved_tenders.user_id -> dismissed_tenders.user_id
Source Relationships
tender_sources.id -> ingestion_runs.source_id -> raw_tender_records.source_id -> tenders.source_id
Tender Relationships
tenders.id -> tender_summaries.tender_id -> tender_embeddings.tender_id -> tender_matches.tender_id -> saved_tenders.tender_id -> dismissed_tenders.tender_id -> processing_errors.tender_id
Business Profile Relationships
business_profiles.id -> business_profile_embeddings.business_profile_id -> tender_matches.business_profile_id -> saved_tenders.business_profile_id -> dismissed_tenders.business_profile_id -> processing_errors.business_profile_id
Deletion Strategy
Foreign-key deletion behavior must be selected carefully.
Deleting a User
Deleting a user may cascade to:
- Business profiles
- Profile embeddings
- Tender matches
- Saved tenders
- Dismissed tenders
Shared tenders must remain intact.
In a production SaaS environment, account deletion may require anonymization or delayed deletion rather than immediate cascading removal.
Deleting a Tender
Tenders should rarely be physically deleted.
If a tender is cancelled, expired, or withdrawn, its status should change.
Physical deletion could remove:
- Historical match results
- User actions
- Processing history
- Audit evidence
A soft-retention approach is safer.
Deleting a Source
A source should normally be deactivated rather than deleted.
Existing tenders and ingestion history should remain available.
Timestamp Strategy
Most tables should include:
created_atupdated_at
Operational tables may also include more specific timestamps:
retrieved_atstarted_atcompleted_atgenerated_atcalculated_atsaved_atdismissed_atoccurred_atresolved_at
All timestamps should use timezone-aware values.
The application should store timestamps in UTC and convert them for display in the user interface.
Primary Key Strategy
UUIDs are suitable for the application because they:
- Avoid exposing sequential internal IDs
- Work well across future distributed components
- Can be generated outside the database
- Reduce identifier collisions during data merging
A typical PostgreSQL UUID primary key can use:
UUID
The application may generate UUID version 4 or another suitable UUID format.
Naming Conventions
The database should use consistent naming.
Recommended conventions:
Table names: plural snake_caseColumn names: snake_casePrimary key: idForeign key: singular_entity_idTimestamps: created_at, updated_atBoolean fields: is_active, is_admin, resolved
Examples:
business_profilestender_sourcessubmission_deadlinebusiness_profile_id
Consistency makes models, migrations, and queries easier to understand.
Indexing Strategy
Indexes should reflect real query patterns.
Tender Indexes
Recommended indexes include:
(source_id, external_id) uniquesubmission_deadlinepublication_datecountry_codenotice_statusprocessing_statusestimated_valuecontracting_authority
A composite dashboard index may later include:
notice_status + submission_deadline
Match Indexes
Recommended indexes include:
(business_profile_id, tender_id) uniquebusiness_profile_idoverall_scorecalculated_at
A particularly useful composite index may be:
business_profile_id + overall_score DESC
This supports ranked tender recommendations.
Save and Dismiss Indexes
(user_id, tender_id) uniqueuser_idsaved_atdismissed_at
Ingestion Indexes
source_idstatusstarted_atexternal_idprocessing_status
Vector Indexes
pgvector can support approximate nearest-neighbor indexes when the dataset becomes large enough.
For a small MVP dataset, exact similarity search may initially be sufficient.
Vector indexes should only be added after:
- The embedding model is fixed
- The vector dimension is known
- Query performance has been measured
Full-Text Search Design
PostgreSQL full-text search can later support searches across:
- Tender title
- Tender description
- Contracting authority
- AI overview
- Deliverables
- Requirements
A generated search-vector column may eventually combine important text fields.
Conceptually:
title+ description+ contracting authority+ AI summary
This should be introduced when the dashboard search feature is implemented.
It does not need to complicate the first database migration.
Database Constraints
The schema should include constraints that prevent obviously invalid data.
Tender Constraints
estimated_value >= 0submission_deadline can be nullcurrency length = 3 when presentcountry_code length = 2 when present
Match Constraints
overall_score >= 0overall_score <= 100exclusion_penalty >= 0
Ingestion Constraints
records_retrieved >= 0records_created >= 0records_updated >= 0records_skipped >= 0records_failed >= 0
Profile Constraints
minimum_contract_value >= 0maximum_contract_value >= 0minimum_contract_value <= maximum_contract_value
Enumeration Strategy
Statuses can be represented using:
- Native PostgreSQL enums
- String columns with check constraints
- Application-level enums
For the MVP, string columns with application enums and database check constraints provide flexibility.
Native database enums can be more difficult to change through migrations.
Example:
status VARCHAR NOT NULLCHECK (status IN ('pending', 'running', 'completed', 'failed'))
Database Schema Diagram
The main MVP relationships can be visualized as follows:
+------------------+| users |+------------------+| id PK || email || full_name |+--------+---------+ | | 1:N v+--------------------------+| business_profiles |+--------------------------+| id PK || user_id FK || company_description || services || preferred_countries || value range |+------------+-------------+ | | 1:1 v+------------------------------+| business_profile_embeddings |+------------------------------+| id PK || business_profile_id FK || embedding || model_name |+------------------------------++------------------+| tender_sources |+------------------+| id PK || name || slug || source_type |+--------+---------+ | | 1:N v+------------------+| ingestion_runs |+------------------+| id PK || source_id FK || status || counters |+--------+---------+ | | 1:N v+-----------------------+| raw_tender_records |+-----------------------+| id PK || source_id FK || ingestion_run_id FK || external_id || payload |+-----------+-----------+ | | 1:1 or N:1 v+--------------------------+| tenders |+--------------------------+| id PK || source_id FK || raw_record_id FK || title || description || deadline || estimated_value |+------+-------------+-----+ | | | 1:1 | 1:1 v v+---------------+ +-------------------+| summaries | | tender_embeddings |+---------------+ +-------------------+| tender_id FK | | tender_id FK || overview | | embedding || requirements | | model_name |+---------------+ +-------------------+business_profiles | | N:M through tender_matches v+------------------------+| tender_matches |+------------------------+| business_profile_id FK || tender_id FK || overall_score || score components || strong_matches || potential_gaps |+------------------------+users + tenders | +---------------------+ | | v v+---------------+ +-------------------+| saved_tenders | | dismissed_tenders |+---------------+ +-------------------+| user_id FK | | user_id FK || tender_id FK | | tender_id FK || note | | reason_code |+---------------+ +-------------------+
Example End-to-End Data Creation
Consider a new tender retrieved from a procurement API.
Step 1: Create an Ingestion Run
A row is added to:
ingestion_runs
with status:
running
Step 2: Store the Raw Record
The original source payload is stored in:
raw_tender_records
Step 3: Normalize the Record
A structured row is inserted into:
tenders
Step 4: Generate the AI Summary
A validated result is stored in:
tender_summaries
Step 5: Generate the Tender Embedding
The vector is stored in:
tender_embeddings
Step 6: Match Against Business Profiles
For every active relevant profile, a record is created or updated in:
tender_matches
Step 7: Display to the User
The dashboard joins:
tender_matchestenderstender_summariessaved_tendersdismissed_tenders
to produce the ranked opportunity list.
Example Dashboard Query Logic
The dashboard needs results that are:
- Matched to the active profile
- Above a minimum score
- Not dismissed
- Not expired
- Ordered by relevance
Conceptually:
SELECT t.id, t.title, t.contracting_authority, t.country_code, t.submission_deadline, t.estimated_value, t.currency, tm.overall_scoreFROM tender_matches tmJOIN tenders t ON t.id = tm.tender_idLEFT JOIN dismissed_tenders dt ON dt.tender_id = t.id AND dt.user_id = :user_idWHERE tm.business_profile_id = :profile_id AND tm.overall_score >= :minimum_score AND dt.id IS NULL AND t.notice_status = 'active'ORDER BY tm.overall_score DESC;
The final implementation will use SQLAlchemy rather than embedding this exact SQL directly.
SQLAlchemy Model Boundaries
The upcoming backend implementation will define SQLAlchemy models for each table.
A likely organization is:
app/models/user.pyapp/models/business_profile.pyapp/models/tender_source.pyapp/models/ingestion.pyapp/models/tender.pyapp/models/tender_summary.pyapp/models/embedding.pyapp/models/tender_match.pyapp/models/user_tender_action.pyapp/models/processing_error.py
A central models package can expose the metadata required by Alembic.
Migration Strategy
The schema should not be created manually in production databases.
Alembic migrations will define each structural change.
A practical migration sequence could be:
1. Create users and business profiles2. Create tender sources and ingestion runs3. Create raw tender records and tenders4. Create summaries and processing errors5. Enable pgvector6. Create embedding tables7. Create tender matches8. Create saved and dismissed tenders9. Add indexes and constraints
The first implementation may combine several of these into one initial migration.
Smaller migrations become preferable after the foundation is stable.
Data Versioning
Several parts of the system require version awareness.
Prompt Version
Stored in:
tender_summaries.prompt_version
Embedding Model Version
Stored in:
tender_embeddings.model_namebusiness_profile_embeddings.model_name
Matching Version
Stored in:
tender_matches.score_version
These fields allow the application to determine whether old records need to be regenerated.
Cost Tracking Considerations
AI cost tracking is not part of the earliest schema, but the summary table already includes token fields.
A later generic ai_usage_events table may contain:
iduser_idtender_idoperationprovidermodel_nameinput_tokensoutput_tokensestimated_costcreated_at
This would support:
- Usage analytics
- Cost monitoring
- Subscription limits
- Model comparisons
For the MVP, summary-level token tracking is sufficient.
Multi-Tenant Upgrade Path
The current schema links business profiles directly to users.
A later team-oriented version may introduce:
organizationsorganization_members
The ownership model would become:
Organization | vBusinessProfile
Users would belong to organizations through memberships.
Because tenders remain globally shared, this change would mainly affect user-owned tables.
We do not need to implement organizations now.
Database Design Decisions
The MVP schema now includes the following decisions.
Decision 1: Store Tenders Globally
A tender is stored once regardless of how many users match it.
Decision 2: Store Matches Per Profile
Relevance is specific to a business profile.
Decision 3: Preserve Raw Payloads
Every imported notice retains its original source data.
Decision 4: Separate AI Output
Summaries and embeddings are stored outside the main tender table.
Decision 5: Track Processing Runs
Every ingestion execution receives an operational record.
Decision 6: Store Match Components
The final relevance score remains explainable.
Decision 7: Separate Saved and Dismissed Actions
Explicit user actions receive their own tables.
Decision 8: Use UUID Primary Keys
Identifiers are not tied to database sequences.
Decision 9: Use JSONB Selectively
Flexible lists and source payloads use JSONB, while core searchable fields use normal columns.
Decision 10: Design for Reprocessing
Hashes, model names, prompt versions, and score versions support controlled regeneration.
Final MVP Table List
The recommended initial database tables are:
usersbusiness_profilestender_sourcesingestion_runsraw_tender_recordstenderstender_summariestender_embeddingsbusiness_profile_embeddingstender_matchessaved_tendersdismissed_tendersprocessing_errors
This schema supports the complete MVP workflow without introducing billing, document processing, team collaboration, or alert-delivery complexity.
Development Principle
The normalized database should contain the information the application understands.
The raw-data layer should contain the information the source actually provided.
These two layers must remain connected but distinct.
This principle allows the system to evolve without losing traceability.
What We Will Build Next
The database schema is now defined conceptually.
The next step is generating the actual backend project structure that will contain:
- FastAPI application setup
- Configuration management
- Database connection code
- API routers
- SQLAlchemy models
- Pydantic schemas
- Repositories
- Services
- Source adapters
- AI integrations
- Matching logic
- Tests
The next article in the development series is:
Generating the FastAPI Project Structure for the AI Tender Opportunity Scanner.
Conclusion
The database schema for the AI Tender Opportunity Scanner has been designed around the core MVP workflow.
Tender data is stored globally.
User-specific relevance is represented through business profiles and tender matches.
Raw source payloads are preserved for traceability.
Normalized tenders power the dashboard and API.
AI summaries and embeddings remain separately versioned.
Saved and dismissed tenders record explicit user actions.
Ingestion runs and processing errors provide operational visibility.
This design gives the application a strong relational foundation without overcomplicating the first release.
With the data model established, we can now begin creating the FastAPI project structure that will turn the architecture and schema into executable code.