Introduction
In the previous article, we built the Knowledge Chunking Engine.
The platform can now transform large organizational documents into smaller, structured knowledge chunks that preserve:
- Headings
- Paragraph boundaries
- Section hierarchy
- Page references
- Lists
- Tables
- Document metadata
- Version relationships
- Tenant ownership
These chunks are suitable for retrieval, but they are still stored as ordinary text.
Traditional keyword search can find exact terms, but tender requirements and organizational documents often describe the same concept using different language.
A tender may ask:
Describe the measures used to maintain service availability during major infrastructure failures.
The organization’s knowledge base may contain a section titled:
Business Continuity and Disaster Recovery
A simple keyword search may not recognize that these texts are closely related because the wording differs.
Semantic search solves this problem.
Instead of comparing exact words, semantic search compares the meaning of the tender requirement with the meaning of each knowledge chunk.
To make this possible, every chunk must be converted into a numerical representation known as an embedding.
An embedding is a vector—a sequence of numbers representing the semantic characteristics of text.
Conceptually:
"Business continuity and disaster recovery"↓Embedding Model↓[0.021, -0.143, 0.882, ...]
Texts with similar meanings produce vectors positioned close to one another in vector space.
The embedding pipeline therefore becomes the bridge between structured organizational knowledge and semantic retrieval.
In this article, we will build a production-ready embedding generation pipeline that:
- Selects an embedding model through configuration
- Supports multiple AI providers
- Generates embeddings in batches
- Stores vectors with each knowledge chunk
- Tracks model and dimension versions
- Retries temporary failures
- Prevents duplicate processing
- Respects tenant isolation
- Tracks token usage and cost
- Supports safe model migration
- Monitors embedding quality
- Queues work in background workers
- Prepares the database for vector search
Development Progress
Proposal Generation Architecture ████████████████████ 100%Organization Knowledge Base ████████████████████ 100%Knowledge Ingestion Pipeline ████████████████████ 100%Knowledge Chunking Engine ████████████████████ 100%Embedding Generation Pipeline ████████████████████ 100%Vector Storage ░░░░░░░░░░░░░░░░░░░░ 0%Semantic Search ░░░░░░░░░░░░░░░░░░░░ 0%Hybrid Retrieval ░░░░░░░░░░░░░░░░░░░░ 0%Retrieval-Augmented Generation ░░░░░░░░░░░░░░░░░░░░ 0%Proposal Templates ░░░░░░░░░░░░░░░░░░░░ 0%Requirement Extraction ░░░░░░░░░░░░░░░░░░░░ 0%Compliance Matrix ░░░░░░░░░░░░░░░░░░░░ 0%Proposal Section Generation ░░░░░░░░░░░░░░░░░░░░ 0%Proposal Review ░░░░░░░░░░░░░░░░░░░░ 0%Proposal Export ░░░░░░░░░░░░░░░░░░░░ 0%
Why Embeddings Matter
Tender requirements and company knowledge rarely use identical wording.
Consider the following tender requirement:
The supplier must explain how critical services remain operational during regional outages.
The knowledge base may contain:
Our business continuity framework includes geographically separated recovery infrastructure, replicated data stores, and tested failover procedures.
These texts share few exact keywords.
However, they express closely related concepts:
- Availability
- Resilience
- Failover
- Disaster recovery
- Business continuity
An embedding model converts both texts into vectors whose distance reflects their semantic relationship.
Tender Requirement Vector≈Knowledge Chunk Vector
This allows the platform to find relevant evidence even when terminology differs.
Keyword Search Versus Semantic Search
Keyword search asks:
Do these texts contain the same words?
Semantic search asks:
Do these texts express the same meaning?
Both approaches are valuable.
Later, the platform will combine:
Keyword Search+Vector Search+Metadata Filters↓Hybrid Retrieval
This article focuses on generating and managing the vectors needed for the vector-search component.
What Is an Embedding?
An embedding is an ordered list of floating-point numbers.
Example:
[ 0.0174, -0.2841, 0.0482, 0.7713, ...]
The number of values is known as the embedding dimension.
Common vector sizes may include:
384 dimensions768 dimensions1024 dimensions1536 dimensions3072 dimensions
The dimension depends on the chosen embedding model.
Vectors from different models—or different dimensions—cannot normally be compared directly.
This means embedding model identity and version must be stored alongside every vector.
Embedding Pipeline Architecture
Knowledge Chunks↓Embedding Job Queue↓Embedding Worker↓Batch Builder↓Embedding Provider↓Vector Validation↓Embedding Storage↓Ready for Semantic Search
Complete Knowledge Processing Flow
The full knowledge pipeline now becomes:
Document Upload↓Malware Scan↓Text Extraction↓Cleaning and Normalization↓Chunk Generation↓Embedding Generation↓Vector Indexing↓Semantic Retrieval
Each stage must complete successfully before the next stage begins.
Design Principles
The embedding pipeline should follow several important principles.
Provider Independence
Application code should not depend directly on one embedding vendor.
Deterministic Input
The same chunk text and embedding configuration should produce a reusable result.
Idempotency
Retrying a job should not create duplicate active embeddings.
Version Awareness
Changing models must not silently mix incompatible vectors.
Tenant Isolation
Every embedding remains connected to an organization and source document.
Cost Visibility
The system must track token usage and embedding cost.
Batch Efficiency
Multiple chunks should be embedded together when supported.
Failure Recovery
Temporary provider failures should be retried safely.
Folder Structure
Add the following structure:
app/├── ai/│ └── embeddings/│ ├── base.py│ ├── registry.py│ ├── schemas.py│ ├── providers/│ │ ├── openai_provider.py│ │ ├── local_provider.py│ │ └── mock_provider.py│ └── exceptions.py│├── knowledge/│ └── embeddings/│ ├── batching.py│ ├── service.py│ ├── repository.py│ ├── validator.py│ ├── hashing.py│ └── workers.py│└── models/ └── knowledge_embedding.py
This separates generic AI provider integration from knowledge-specific orchestration.
Embedding Model Selection
The embedding model should be selected based on:
- Retrieval quality
- Supported languages
- Vector dimension
- Maximum input length
- Latency
- Batch capacity
- Data-processing requirements
- Deployment region
- Cost
- Provider reliability
- Self-hosting requirements
The system should not hard-code a model name throughout the codebase.
Use configuration instead.
Embedding Configuration
Add settings such as:
EMBEDDING_PROVIDEREMBEDDING_MODELEMBEDDING_DIMENSIONEMBEDDING_BATCH_SIZEEMBEDDING_MAX_INPUT_TOKENSEMBEDDING_TIMEOUT_SECONDSEMBEDDING_MAX_RETRIESEMBEDDING_NORMALIZE_VECTORS
Pydantic Settings
from pydantic import Fieldfrom pydantic_settings import BaseSettingsclass EmbeddingSettings(BaseSettings): provider: str = Field( default="openai", validation_alias="EMBEDDING_PROVIDER", ) model: str = Field( validation_alias="EMBEDDING_MODEL" ) dimension: int = Field( gt=0, validation_alias=( "EMBEDDING_DIMENSION" ), ) batch_size: int = Field( default=64, ge=1, le=512, validation_alias=( "EMBEDDING_BATCH_SIZE" ), ) max_input_tokens: int = Field( default=8191, ge=1, validation_alias=( "EMBEDDING_MAX_INPUT_TOKENS" ), ) timeout_seconds: int = Field( default=60, ge=1, validation_alias=( "EMBEDDING_TIMEOUT_SECONDS" ), ) max_retries: int = Field( default=5, ge=0, validation_alias=( "EMBEDDING_MAX_RETRIES" ), ) normalize_vectors: bool = Field( default=True, validation_alias=( "EMBEDDING_NORMALIZE_VECTORS" ), )
Embedding Provider Interface
Create:
app/ai/embeddings/base.py
from abc import ABC, abstractmethodfrom dataclasses import dataclassdataclass(frozen=True)class EmbeddingRequestItem: reference_id: str text: strdataclass(frozen=True)class EmbeddingResultItem: reference_id: str vector: list[float] token_count: int | Nonedataclass(frozen=True)class EmbeddingBatchResult: items: list[EmbeddingResultItem] provider: str model: str dimension: int total_tokens: int | Noneclass EmbeddingProvider(ABC): abstractmethod def embed( self, *, items: list[EmbeddingRequestItem], ) -> EmbeddingBatchResult: raise NotImplementedError
The rest of the application should depend on this interface rather than a vendor SDK.
Why Use Reference IDs?
Providers may return results in positional order, but explicit reference identifiers make the application safer.
Example:
chunk:1001chunk:1002chunk:1003
When processing responses, the system can map each vector back to the correct chunk without relying only on array position.
Provider Registry
Create:
app/ai/embeddings/registry.py
class EmbeddingProviderRegistry: def __init__( self, providers: dict[ str, EmbeddingProvider, ], ): self.providers = providers def get( self, provider_name: str, ) -> EmbeddingProvider: provider = self.providers.get( provider_name ) if provider is None: raise UnsupportedEmbeddingProvider( provider_name ) return provider
This allows the platform to support cloud and self-hosted models through the same service layer.
Preparing Text for Embedding
The text sent to the embedding model should include enough context to make the vector meaningful.
Embedding only the chunk body may lose important structural information.
A better input combines:
Document titleCategorySection hierarchyChunk headingChunk content
Example:
Document: Information Security PolicyCategory: SecuritySection: Incident Management > Incident ResponseContent:Our incident response team...
Embedding Input Builder
dataclass(frozen=True)class ChunkEmbeddingInput: chunk_id: int text: str input_hash: strclass ChunkEmbeddingInputBuilder: def build( self, *, chunk: KnowledgeChunk, document: KnowledgeDocument, ) -> ChunkEmbeddingInput: parts = [ f"Document: {document.title}", f"Category: {document.category.value}", ] if chunk.section_path: parts.append( "Section: " + " > ".join( chunk.section_path ) ) elif chunk.heading: parts.append( f"Section: {chunk.heading}" ) parts.append( f"Content:\n{chunk.content}" ) text = "\n".join(parts) return ChunkEmbeddingInput( chunk_id=chunk.id, text=text, input_hash=calculate_text_hash( text ), )
Why Hash the Embedding Input?
The chunk content may remain unchanged while metadata such as the heading or document title changes.
Because these values influence the embedding input, the system should hash the final constructed text rather than only the chunk body.
Embedding Input↓SHA-256↓Input Hash
If the input hash and model configuration have not changed, the existing embedding can be reused.
Hash Utility
import hashlibdef calculate_text_hash( text: str,) -> str: normalized = text.strip().encode( "utf-8" ) return hashlib.sha256( normalized ).hexdigest()
Embedding Database Design
There are two primary storage options.
Store the Vector on the Chunk
knowledge_chunks.embedding
This is simple but makes model migration and multiple embedding versions difficult.
Use a Separate Embedding Table
knowledge_embeddings
This supports:
- Multiple models
- Model migrations
- Re-embedding
- Historical records
- Provider changes
- A/B testing
- Different retrieval indexes
For a production SaaS, use a separate table.
Knowledge Embedding Table
Create:
knowledge_embeddings
Recommended fields:
idorganization_idknowledge_chunk_iddocument_iddocument_version_idprovidermodelmodel_revisiondimensioninput_hashvectorstatustoken_countestimated_costerror_codeerror_messagegenerated_atcreated_atupdated_at
Embedding Status
Use statuses such as:
PENDINGPROCESSINGREADYFAILEDSUPERSEDEDDELETED
Only READY embeddings should be included in semantic search.
SQLAlchemy Model
The exact vector column type will depend on the vector-storage implementation added in the next article.
For now, define the model conceptually with a vector-compatible column.
import enumfrom datetime import datetimefrom decimal import Decimalfrom sqlalchemy import ( DateTime, Enum, ForeignKey, Index, Integer, Numeric, String, Text, UniqueConstraint,)from sqlalchemy.orm import ( Mapped, mapped_column,)from app.database.base import Baseclass KnowledgeEmbeddingStatus( str, enum.Enum,): PENDING = "pending" PROCESSING = "processing" READY = "ready" FAILED = "failed" SUPERSEDED = "superseded" DELETED = "deleted"class KnowledgeEmbedding(Base): __tablename__ = ( "knowledge_embeddings" ) id: Mapped[int] = mapped_column( primary_key=True ) organization_id: Mapped[int] = ( mapped_column( ForeignKey( "organizations.id", ondelete="CASCADE", ), nullable=False, index=True, ) ) knowledge_chunk_id: Mapped[int] = ( mapped_column( ForeignKey( "knowledge_chunks.id", ondelete="CASCADE", ), nullable=False, index=True, ) ) document_id: Mapped[int] = ( mapped_column( ForeignKey( "knowledge_documents.id", ondelete="CASCADE", ), nullable=False, index=True, ) ) document_version_id: Mapped[int] = ( mapped_column( ForeignKey( "knowledge_document_versions.id", ondelete="CASCADE", ), nullable=False, index=True, ) ) provider: Mapped[str] = mapped_column( String(100), nullable=False, ) model: Mapped[str] = mapped_column( String(255), nullable=False, ) model_revision: Mapped[ str | None ] = mapped_column( String(255), nullable=True, ) dimension: Mapped[int] = mapped_column( Integer, nullable=False, ) input_hash: Mapped[str] = mapped_column( String(64), nullable=False, ) status: Mapped[ KnowledgeEmbeddingStatus ] = mapped_column( Enum( KnowledgeEmbeddingStatus, name=( "knowledge_embedding_status" ), ), default=( KnowledgeEmbeddingStatus.PENDING ), nullable=False, index=True, ) token_count: Mapped[int | None] = ( mapped_column( Integer, nullable=True, ) ) estimated_cost: Mapped[ Decimal | None ] = mapped_column( Numeric(18, 8), nullable=True, ) error_code: Mapped[ str | None ] = mapped_column( String(100), nullable=True, ) error_message: Mapped[ str | None ] = mapped_column( Text, nullable=True, ) generated_at: Mapped[ datetime | None ] = mapped_column( DateTime(timezone=True), nullable=True, ) created_at: Mapped[datetime] = ( mapped_column( DateTime(timezone=True), nullable=False, ) ) updated_at: Mapped[datetime] = ( mapped_column( DateTime(timezone=True), nullable=False, ) ) __table_args__ = ( UniqueConstraint( "knowledge_chunk_id", "provider", "model", "input_hash", name=( "uq_knowledge_embedding_input" ), ), Index( "ix_embedding_org_model_status", "organization_id", "model", "status", ), Index( "ix_embedding_version_status", "document_version_id", "status", ), )
The vector column will be added when configuring pgvector or another vector backend.
Why Repeat Tenant and Document Identifiers?
The embedding links to a knowledge chunk, and the chunk already links to the organization and document.
However, storing these fields directly provides:
- Faster tenant-scoped filtering
- Simpler vector queries
- Easier deletion
- Better index design
- Reduced join requirements
- Stronger auditing
The service must verify that all identifiers belong to the same tenant and source hierarchy.
Vector Validation
Every provider response must be validated before storage.
Validate:
Result count equals request countReference IDs matchVector is not emptyDimension is correctValues are numericValues are finiteVector norm is validNo missing chunks
Vector Validator
import mathclass EmbeddingVectorValidator: def validate( self, *, vector: list[float], expected_dimension: int, ) -> None: if len(vector) != expected_dimension: raise EmbeddingDimensionMismatch( expected=expected_dimension, actual=len(vector), ) if not vector: raise EmptyEmbeddingVector() for value in vector: if not isinstance( value, int | float, ): raise InvalidEmbeddingValue() if not math.isfinite(value): raise NonFiniteEmbeddingValue()
Vector Normalization
Some similarity functions work best with normalized vectors.
L2 normalization transforms a vector so its magnitude equals one.
vector↓divide by vector magnitude↓unit vector
Normalization Utility
import mathdef l2_normalize( vector: list[float],) -> list[float]: magnitude = math.sqrt( sum( value * value for value in vector ) ) if magnitude == 0: raise ZeroMagnitudeEmbedding() return [ value / magnitude for value in vector ]
Whether application-side normalization is required depends on the model and similarity metric.
The decision must remain consistent across indexing and querying.
Similarity Metrics
Common vector similarity methods include:
Cosine SimilarityInner ProductEuclidean Distance
The choice influences:
- Vector normalization
- Index configuration
- Ranking behavior
- Threshold calibration
A common starting point for normalized text embeddings is cosine similarity.
The next article will configure the vector storage and indexes.
Batch Generation
Sending one API request per chunk is inefficient.
Instead:
Chunk 1Chunk 2Chunk 3...Chunk 64↓One Embedding Request
Benefits include:
- Lower network overhead
- Better throughput
- Lower latency per chunk
- Fewer provider requests
- More efficient worker execution
Batch Size Constraints
Batch size may be constrained by:
- Maximum number of inputs
- Maximum total tokens
- Maximum request payload
- Provider rate limits
- Worker memory
- Timeout limits
The batch builder must consider both item count and token count.
Batch Builder
dataclass(frozen=True)class EmbeddingBatch: items: list[ChunkEmbeddingInput] estimated_tokens: intclass EmbeddingBatchBuilder: def __init__( self, *, max_items: int, max_total_tokens: int, ): self.max_items = max_items self.max_total_tokens = ( max_total_tokens ) def build( self, inputs: list[ ChunkEmbeddingInput ], ) -> list[EmbeddingBatch]: batches: list[EmbeddingBatch] = [] current: list[ ChunkEmbeddingInput ] = [] current_tokens = 0 for item in inputs: item_tokens = estimate_tokens( item.text ) exceeds_items = ( len(current) >= self.max_items ) exceeds_tokens = ( current and current_tokens + item_tokens > self.max_total_tokens ) if ( exceeds_items or exceeds_tokens ): batches.append( EmbeddingBatch( items=current, estimated_tokens=( current_tokens ), ) ) current = [] current_tokens = 0 current.append(item) current_tokens += item_tokens if current: batches.append( EmbeddingBatch( items=current, estimated_tokens=( current_tokens ), ) ) return batches
Oversized Chunk Handling
A chunk should already fall within configured token limits because the chunking engine controls size.
However, the embedding service must still defend against oversized inputs.
Possible actions:
Reject inputRe-chunk contentTruncate with warningMark processing failure
For knowledge quality, do not silently truncate.
The preferred behavior is:
Oversized chunk↓Mark chunk for reprocessing↓Generate smaller chunks↓Retry embedding
Embedding Repository
Create:
app/knowledge/embeddings/repository.py
The repository should support:
- Finding reusable embeddings
- Creating pending embedding records
- Marking records as processing
- Storing vectors
- Marking failures
- Superseding previous embeddings
- Querying missing embeddings
- Deleting embeddings by document version
Finding Reusable Embeddings
def find_ready_embedding( self, *, organization_id: int, chunk_id: int, provider: str, model: str, input_hash: str,) -> KnowledgeEmbedding | None: statement = ( select(KnowledgeEmbedding) .where( KnowledgeEmbedding .organization_id == organization_id, KnowledgeEmbedding .knowledge_chunk_id == chunk_id, KnowledgeEmbedding.provider == provider, KnowledgeEmbedding.model == model, KnowledgeEmbedding.input_hash == input_hash, KnowledgeEmbedding.status == KnowledgeEmbeddingStatus.READY, ) ) return self.session.scalar( statement )
This prevents unnecessary provider calls.
Idempotent Processing
The same background job may run more than once due to:
- Worker restart
- Queue redelivery
- Timeout ambiguity
- Manual retry
- Deployment interruption
The embedding workflow must remain safe.
Job runs↓Ready embedding exists?Yes → SkipNo → Generate
A unique database constraint provides an additional safety layer.
Concurrency Control
Two workers might attempt to embed the same chunk simultaneously.
Possible protection mechanisms include:
- Database row locking
- Unique constraints
- Distributed locks
- Job deduplication keys
- Atomic status transitions
A practical approach is:
Create or lock embedding record↓Change PENDING to PROCESSING↓Only one worker succeeds
Atomic Claim Query
def claim_pending_embedding( self, *, embedding_id: int,) -> bool: statement = ( update(KnowledgeEmbedding) .where( KnowledgeEmbedding.id == embedding_id, KnowledgeEmbedding.status == KnowledgeEmbeddingStatus.PENDING, ) .values( status=( KnowledgeEmbeddingStatus .PROCESSING ), updated_at=utc_now(), ) ) result = self.session.execute( statement ) return result.rowcount == 1
Only the worker that successfully claims the record should process it.
Embedding Generation Service
Create:
app/knowledge/embeddings/service.py
The service coordinates:
- Chunk loading
- Tenant validation
- Availability checks
- Input construction
- Hashing
- Reuse detection
- Batching
- Provider calls
- Validation
- Cost calculation
- Persistence
- Usage tracking
- Downstream indexing jobs
Service Workflow
Load Eligible Chunks↓Build Embedding Inputs↓Skip Existing Embeddings↓Create Pending Records↓Build Batches↓Call Provider↓Validate Vectors↓Normalize if Required↓Persist Results↓Track Usage↓Mark Chunks Ready
Embedding Service Example
class KnowledgeEmbeddingService: def generate_for_version( self, *, organization_id: int, document_version_id: int, ) -> EmbeddingGenerationSummary: chunks = ( self.chunk_repository .list_for_embedding( organization_id=organization_id, document_version_id=( document_version_id ), ) ) provider = self.provider_registry.get( self.settings.provider ) pending_inputs = [] for chunk in chunks: document = chunk.document embedding_input = ( self.input_builder.build( chunk=chunk, document=document, ) ) existing = ( self.embedding_repository .find_ready_embedding( organization_id=( organization_id ), chunk_id=chunk.id, provider=( self.settings.provider ), model=self.settings.model, input_hash=( embedding_input.input_hash ), ) ) if existing: continue pending_inputs.append( embedding_input ) batches = self.batch_builder.build( pending_inputs ) generated_count = 0 total_tokens = 0 for batch in batches: result = provider.embed( items=[ EmbeddingRequestItem( reference_id=( f"chunk:{item.chunk_id}" ), text=item.text, ) for item in batch.items ] ) self._validate_batch_result( batch=batch, result=result, ) self._persist_batch( organization_id=( organization_id ), document_version_id=( document_version_id ), batch=batch, result=result, ) generated_count += len( result.items ) total_tokens += ( result.total_tokens or 0 ) return EmbeddingGenerationSummary( generated_count=generated_count, skipped_count=( len(chunks) - generated_count ), total_tokens=total_tokens, )
Production code should use explicit transactions and robust partial-batch failure handling.
Eligibility Rules
Not every chunk should be embedded.
The system should verify:
Document belongs to organizationDocument version is processedChunk passed validationChunk is activeDocument is not deletedDocument is eligible for indexingConfidentiality policy permits provider use
Approval policy requires a deliberate decision.
Two possible strategies exist.
Embed Before Approval
Advantages:
- Reviewers can test search
- Approval becomes faster
- Processing completes before publication
Disadvantages:
- Unapproved content is sent to the embedding provider
- Processing cost may be wasted
Embed After Approval
Advantages:
- Only approved content is processed
- Lower unnecessary AI cost
- Stronger governance
Disadvantages:
- Approval-to-availability delay
A practical design can generate embeddings after text review but keep them excluded from retrieval until approval.
Confidentiality-Aware Provider Routing
Restricted content may require a specific embedding environment.
Example:
Internal document↓Approved cloud providerConfidential document↓Enterprise no-retention endpointRestricted document↓Self-hosted model
The provider router should evaluate:
- Confidentiality level
- Organization policy
- Data residency
- Subscription plan
- Model availability
- Regional requirements
Provider Routing Service
class EmbeddingProviderRouter: def select( self, *, organization: Organization, document: KnowledgeDocument, ) -> EmbeddingRoute: policy = ( organization.ai_data_policy ) if ( document.confidentiality_level == ConfidentialityLevel.RESTRICTED ): return self._restricted_route( policy ) return self._default_route( policy )
This keeps sensitive-data policy outside the generic embedding provider.
Background Worker
Embedding generation should run asynchronously.
Chunking Completed↓Embedding Job Queued↓Worker Claims Job↓Embeddings Generated↓Vector Indexing Ready
Worker Task Example
task( name=( "knowledge.generate_embeddings" ), autoretry_for=( TemporaryEmbeddingError, ), retry_backoff=True, retry_jitter=True, max_retries=5,)def generate_embeddings_task( organization_id: int, document_version_id: int,): service = ( build_knowledge_embedding_service() ) return service.generate_for_version( organization_id=organization_id, document_version_id=( document_version_id ), )
Do not pass document text or vectors through the queue payload.
Pass identifiers and load the records securely inside the worker.
Processing Job Integration
The existing knowledge_processing_jobs table already supports:
EMBEDDING
The worker should update the job lifecycle:
QUEUED↓RUNNING↓COMPLETED
or:
RUNNING↓RETRYING↓FAILED
Retry Strategy
Retry temporary failures such as:
- Rate limits
- Provider timeouts
- Network errors
- Temporary server errors
- Connection resets
- Short-lived database failures
Do not automatically retry permanent failures such as:
- Unsupported model
- Invalid API key
- Dimension mismatch
- Oversized input
- Policy violation
- Invalid chunk data
Retry Backoff
Use exponential backoff with jitter.
Example:
Attempt 1:5 secondsAttempt 2:15 secondsAttempt 3:45 secondsAttempt 4:135 seconds
Jitter prevents many failed jobs from retrying simultaneously.
Partial Batch Failure
A batch request may fail entirely, or individual items may be invalid.
The pipeline should support:
Batch fails temporarily↓Retry entire batch
and:
One item permanently invalid↓Mark item failed↓Continue remaining items
This may require splitting a failed batch to isolate problematic inputs.
Batch Failure Isolation
Batch of 64 fails↓Split into two batches of 32↓Retry↓Continue splitting if required↓Identify invalid chunk
This prevents one bad item from blocking an entire document.
Embedding Cost Tracking
Embedding generation consumes tokens and may incur provider cost.
Track:
OrganizationDocument versionProviderModelInput tokensNumber of chunksNumber of requestsEstimated costProcessing duration
Cost Calculation
from decimal import Decimaldef calculate_embedding_cost( *, token_count: int, price_per_million_tokens: Decimal,) -> Decimal: return ( Decimal(token_count) / Decimal(1_000_000) * price_per_million_tokens )
Pricing should come from a versioned model-pricing configuration rather than being hard-coded into business logic.
AI Usage Event
Record an event such as:
{ "operation": "knowledge_embedding", "organization_id": 42, "document_version_id": 118, "provider": "configured_provider", "model": "configured_model", "input_tokens": 48120, "output_units": 612, "estimated_cost": "0.00384960"}
Do not store sensitive chunk content in the AI usage record.
Quota Enforcement
Before generating embeddings, verify:
Monthly AI budgetEmbedding token allowanceKnowledge storage allowanceDocument processing entitlementProvider access policy
Possible plan limits:
knowledge.embedding_tokens.monthlyknowledge.embedded_chunks.maxknowledge.embedding_models.allowedknowledge.private_embeddings.enabled
Budget Failure Behavior
If the organization exceeds its embedding budget:
Mark job as BLOCKED↓Notify administrator↓Keep extracted chunks↓Resume after quota increase
Do not discard processed knowledge.
Embedding Versioning
Every embedding is tied to:
ProviderModelModel revisionDimensionInput hashGeneration configuration
This combination acts as the embedding version.
Why Model Versioning Matters
Suppose the platform changes from:
Embedding Model A1536 dimensions
to:
Embedding Model B1024 dimensions
Old and new vectors cannot safely share the same index.
The migration must be explicit.
Embedding Profile
Create a logical profile identifier.
knowledge-default-v1
The profile may point to:
Provider:provider-aModel:embedding-model-aDimension:1536Normalization:L2Similarity:Cosine
A later profile may be:
knowledge-default-v2
Application retrieval uses a profile rather than scattering raw model names across the codebase.
Embedding Profile Model
A future configuration table may contain:
embedding_profilesidnameprovidermodeldimensionnormalizationsimilarity_metricstatuscreated_atactivated_atretired_at
For the first version, profiles can remain configuration-based.
Model Migration Strategy
A safe re-embedding migration follows:
Create New Embedding Profile↓Generate New Embeddings↓Build New Vector Index↓Run Retrieval Evaluation↓Switch Search Traffic↓Monitor↓Retire Old Profile
Never delete the old embeddings before the new index is validated.
Dual-Write Migration
During a gradual model migration:
New Chunk↓Generate v1 embedding+Generate v2 embedding
Retrieval continues using v1 until v2 is ready.
This reduces migration downtime.
Superseding Embeddings
When a chunk’s embedding input changes:
Existing READY embedding↓New input hash detected↓Generate new embedding↓Mark old embedding SUPERSEDED
Historical embeddings may be retained temporarily for audit or rollback.
Document Version Changes
Each document version has its own chunks and embeddings.
Policy v1↓Chunks v1↓Embeddings v1Policy v2↓Chunks v2↓Embeddings v2
Approving version 2 should eventually remove version 1 from the active retrieval index while preserving its evidence history.
Deletion Workflow
When a knowledge version is deleted, remove or deactivate:
ChunksEmbeddingsVector index entriesCached retrieval resultsProcessing artifacts
A deletion event should be idempotent.
Alembic Migration
Create a migration:
alembic revision \ --autogenerate \ -m "create knowledge embeddings"
The migration should create:
knowledge_embeddingsEmbedding status enumTenant indexesModel indexesUniqueness constraints
The vector column and vector indexes will be finalized in the next article.
Migration Example
def upgrade() -> None: op.create_table( "knowledge_embeddings", sa.Column( "id", sa.BigInteger(), primary_key=True, ), sa.Column( "organization_id", sa.BigInteger(), nullable=False, ), sa.Column( "knowledge_chunk_id", sa.BigInteger(), nullable=False, ), sa.Column( "document_id", sa.BigInteger(), nullable=False, ), sa.Column( "document_version_id", sa.BigInteger(), nullable=False, ), sa.Column( "provider", sa.String(length=100), nullable=False, ), sa.Column( "model", sa.String(length=255), nullable=False, ), sa.Column( "dimension", sa.Integer(), nullable=False, ), sa.Column( "input_hash", sa.String(length=64), nullable=False, ), sa.Column( "status", knowledge_embedding_status, nullable=False, ), # Vector column added with # selected vector extension. )
Always review generated migrations manually.
API Design
Most embedding operations should not be exposed as ordinary user-facing APIs.
The normal workflow is automatic:
Chunking completes↓Embedding job starts
However, administrative endpoints are useful.
Recommended endpoints:
GET /knowledge/documents/{document_id}/embedding-statusPOST /knowledge/versions/{version_id}/embeddings/retryPOST /knowledge/versions/{version_id}/embeddings/rebuildGET /admin/embedding-jobsGET /admin/embedding-models
Embedding Status Response
class EmbeddingStatusResponse( BaseModel): document_version_id: int total_chunks: int ready_embeddings: int pending_embeddings: int processing_embeddings: int failed_embeddings: int model: str dimension: int completed: bool
Retry Endpoint
router.post( "/knowledge/versions/" "{version_id}/embeddings/retry", status_code=status.HTTP_202_ACCEPTED,)def retry_failed_embeddings( version_id: int, current_user: User = Depends( get_current_user ), organization: Organization = Depends( get_current_organization ), service: KnowledgeEmbeddingService = ( Depends( get_knowledge_embedding_service ) ),): require_permission( user=current_user, organization=organization, permission=( "knowledge:reprocess" ), ) return service.retry_failed( organization_id=organization.id, document_version_id=version_id, actor_user_id=current_user.id, )
Rebuild Security
A full embedding rebuild can consume substantial AI resources.
Restrict it to:
Knowledge ManagerOrganization AdministratorPlatform Administrator
The request should also require:
- Budget check
- Audit log
- Idempotency key
- Rate limiting
- Confirmation in the frontend
React Processing Status
The knowledge document page can display:
Text extraction CompleteChunk generation CompleteEmbedding generation 482 / 612Vector indexing Waiting
Embedding Progress Component
export function EmbeddingProgress({ status,}) { const percentage = status.total_chunks === 0 ? 0 : Math.round( (status.ready_embeddings / status.total_chunks) * 100 ); return ( <section> <h3>Semantic indexing</h3> <progress value={status.ready_embeddings} max={status.total_chunks} /> <p> {status.ready_embeddings} of{" "} {status.total_chunks} chunks embedded ({percentage}%) </p> {status.failed_embeddings > 0 && ( <p role="alert"> {status.failed_embeddings} chunks failed and require attention. </p> )} </section> );}
Frontend Retry Action
Users with permission can see:
Retry failed embeddings
The interface should not expose provider secrets, raw vectors, or internal stack traces.
Audit Logging
Record events such as:
embedding_job_queuedembedding_generation_startedembedding_batch_completedembedding_generation_failedembedding_generation_retriedembedding_model_changedembedding_rebuild_requestedembedding_profile_activated
Audit metadata should include:
organization_idactor_user_iddocument_version_idembedding_profilechunk_countresulttimestamp
Structured Operational Logs
Example:
{ "event": "embedding_batch_completed", "organization_id": 42, "document_version_id": 118, "provider": "configured_provider", "model": "configured_model", "batch_size": 64, "token_count": 3120, "duration_ms": 842, "correlation_id": "job_..."}
Do not log vectors or full source text.
Metrics
Add metrics such as:
knowledge_embedding_jobs_totalknowledge_embedding_jobs_failed_totalknowledge_embedding_chunks_totalknowledge_embedding_tokens_totalknowledge_embedding_cost_totalknowledge_embedding_batch_sizeknowledge_embedding_duration_secondsknowledge_embedding_provider_requests_totalknowledge_embedding_provider_errors_totalknowledge_embedding_dimension_mismatch_totalknowledge_embedding_queue_depth
Metric Labels
Reasonable labels include:
providermodelstatuserror_type
Avoid high-cardinality labels such as:
document_idchunk_idorganization_id
Tenant-level usage should be stored in database records or analytics events rather than infrastructure metric labels.
Monitoring Dashboard
The operations dashboard should show:
Embedding queue depthJobs runningJobs failedChunks embedded per minuteProvider latencyRate-limit eventsToken usageDaily costAverage batch sizeCurrent embedding profile
Alerts
Create alerts for:
Queue depth above thresholdFailure rate above thresholdProvider unavailableRate-limit surgeDimension mismatchUnexpected cost increaseNo worker activityEmbedding latency degradation
Quality Monitoring
A technically valid embedding is not automatically a useful embedding.
Quality should be evaluated through retrieval tests.
Create a benchmark dataset containing:
Search queryExpected documentExpected chunkRelevant categoryLanguageMinimum ranking target
Example:
Query:How do we recover critical systems after a major outage?Expected source:Business Continuity PolicyExpected section:Disaster Recovery
Retrieval Quality Metrics
Later semantic-search tests should measure:
Recall@KPrecision@KMean Reciprocal RankNormalized Discounted Cumulative GainRelevant Source Coverage
Embedding quality must be evaluated in the context of actual tender and proposal queries.
Multilingual Embeddings
Organizations may store knowledge in multiple languages.
Example:
Tender:DutchKnowledge:EnglishProposal:Dutch
A multilingual embedding model may retrieve semantically related content across languages.
The platform should store:
Chunk languageQuery languageEmbedding model language support
This allows retrieval evaluation by language pair.
Language-Specific Routing
A provider router may choose:
English-optimized modelMultilingual modelDomain-specific model
based on the document language and organization’s configuration.
For the initial version, using one multilingual model may reduce operational complexity.
Embedding Security
Embedding vectors are derived data, but they should still be treated as sensitive.
Vectors may reveal structural or semantic information about confidential content.
Protect them with:
- Tenant isolation
- Database encryption
- Private networking
- Access controls
- Secure backups
- Deletion workflows
- Restricted administrative access
Do not expose raw vectors through ordinary APIs.
External Provider Controls
Before sending text to an external embedding provider, enforce:
Approved providerApproved processing regionOrganization consentData-retention policyConfidentiality compatibilityTransport encryptionProvider authentication
For highly sensitive deployments, support a self-hosted embedding service.
Secret Management
Provider credentials must be stored in:
Secret managerEncrypted runtime environmentManaged identity
Never store credentials in:
Source codeGit repositoryDatabase logsFrontend configurationJob payloads
Rate Limiting
The platform must respect both:
- Provider rate limits
- Internal tenant quotas
Use a rate limiter based on:
Requests per minuteTokens per minuteConcurrent batchesOrganization budget
A centralized limiter prevents multiple workers from overwhelming the provider.
Caching
Embedding results can be reused when the complete embedding input and configuration are identical.
Cache key:
providermodelmodel revisiondimensionnormalizationinput hash
Cross-tenant reuse requires careful privacy analysis.
The safest initial implementation limits cache reuse to the same organization and chunk record.
Backpressure
When the embedding queue grows too quickly:
Reduce worker intakePause low-priority rebuildsPrioritize newly approved documentsNotify operations
Priority classes may include:
High:Newly approved knowledgeMedium:New document processingLow:Historical re-embedding
Job Prioritization
Proposal generation may require a document urgently.
An organization could request:
Priority indexing
The queue can support:
proposal_blockinginteractivestandardmaintenance
This should remain constrained by plan and budget.
Failure States in the User Interface
Users should see actionable messages.
Bad:
Embedding error
Better:
Semantic indexing failed for 3 chunks.The original document remains available.A knowledge manager can retry processing.
Do not display provider stack traces or secret-bearing messages.
Testing Strategy
Create:
tests/└── knowledge/ └── embeddings/ ├── test_input_builder.py ├── test_hashing.py ├── test_batch_builder.py ├── test_vector_validator.py ├── test_normalization.py ├── test_embedding_repository.py ├── test_embedding_service.py ├── test_embedding_worker.py ├── test_idempotency.py ├── test_retry_behavior.py ├── test_tenant_isolation.py ├── test_cost_tracking.py └── test_model_migration.py
Input Builder Test
def test_embedding_input_contains_context( knowledge_chunk, knowledge_document, input_builder,): result = input_builder.build( chunk=knowledge_chunk, document=knowledge_document, ) assert knowledge_document.title in ( result.text ) assert knowledge_chunk.heading in ( result.text ) assert knowledge_chunk.content in ( result.text )
Batch Size Test
def test_batch_builder_respects_item_limit(): builder = EmbeddingBatchBuilder( max_items=2, max_total_tokens=10_000, ) inputs = [ make_embedding_input(1), make_embedding_input(2), make_embedding_input(3), ] batches = builder.build(inputs) assert len(batches) == 2 assert len(batches[0].items) == 2 assert len(batches[1].items) == 1
Token Limit Test
def test_batch_builder_respects_token_limit(): builder = EmbeddingBatchBuilder( max_items=100, max_total_tokens=100, ) inputs = [ make_embedding_input( 1, estimated_tokens=70, ), make_embedding_input( 2, estimated_tokens=60, ), ] batches = builder.build(inputs) assert len(batches) == 2
Dimension Validation Test
def test_vector_dimension_must_match(): validator = EmbeddingVectorValidator() with pytest.raises( EmbeddingDimensionMismatch ): validator.validate( vector=[0.1, 0.2], expected_dimension=3, )
Vector Normalization Test
def test_l2_normalization(): result = l2_normalize( [3.0, 4.0] ) magnitude = math.sqrt( sum( value * value for value in result ) ) assert magnitude == pytest.approx( 1.0 )
Idempotency Test
def test_existing_embedding_is_reused( service, embedding_repository, ready_embedding,): summary = service.generate_for_version( organization_id=( ready_embedding.organization_id ), document_version_id=( ready_embedding .document_version_id ), ) assert summary.generated_count == 0 assert summary.skipped_count == 1
Tenant Isolation Test
def test_embedding_query_is_tenant_scoped( repository, embedding_factory, organization_a, organization_b,): embedding = embedding_factory( organization_id=organization_a.id ) result = repository.get_by_id( organization_id=organization_b.id, embedding_id=embedding.id, ) assert result is None
Temporary Failure Test
def test_temporary_provider_failure_is_retried( worker, provider,): provider.embed.side_effect = [ TemporaryEmbeddingError(), successful_embedding_result(), ] result = worker.run( organization_id=42, document_version_id=118, ) assert result.completed is True assert provider.embed.call_count == 2
Permanent Failure Test
def test_dimension_mismatch_is_not_retried( worker, provider,): provider.embed.return_value = ( result_with_wrong_dimension() ) with pytest.raises( EmbeddingDimensionMismatch ): worker.run( organization_id=42, document_version_id=118, ) assert provider.embed.call_count == 1
Cost Tracking Test
def test_embedding_usage_is_recorded( service, usage_repository,): service.generate_for_version( organization_id=42, document_version_id=118, ) event = usage_repository.latest() assert event.operation == ( "knowledge_embedding" ) assert event.input_tokens > 0 assert event.estimated_cost >= 0
Model Migration Test
Verify that:
Old embedding profile remains searchableNew profile generates separate embeddingsDimensions are not mixedTraffic switches only after validationRollback returns to old profile
Integration Test
A complete integration test should execute:
Create knowledge document↓Create approved version↓Store extracted text↓Generate chunks↓Queue embedding job↓Mock provider returns vectors↓Persist embeddings↓Mark processing complete
End-to-End Processing Scenario
1. A knowledge manager uploads a policy2. The file passes malware scanning3. Text is extracted and normalized4. The document is divided into 84 chunks5. An embedding job is queued6. The worker loads eligible chunks7. Embedding inputs include headings and document context8. Existing vectors are skipped9. Remaining inputs are grouped into batches10. The provider generates vectors11. Every vector is validated12. Vectors are normalized when configured13. Embeddings are stored with model metadata14. Token usage and cost are recorded15. The processing job is marked complete16. The interface displays 84 of 84 chunks embedded17. The document becomes ready for vector indexing
Failure Scenario
1. A batch reaches the provider rate limit2. The provider adapter classifies the error as temporary3. The job enters RETRYING4. Exponential backoff is applied5. The batch succeeds on a later attempt6. Existing completed batches are not regenerated7. Usage is recorded only for successful provider work8. The overall job completes
Model Upgrade Scenario
1. The platform creates embedding profile v22. Existing profile v1 remains active3. Background workers generate v2 embeddings4. A separate vector index is built5. Retrieval benchmarks compare v1 and v26. Search traffic is switched to v27. Monitoring confirms quality and latency8. v1 remains available for rollback9. v1 is retired after the migration window
Deliverables
At the end of this article, the project now has:
✔ Provider-independent embedding interface✔ Embedding provider registry✔ Configurable embedding profiles✔ Context-enriched embedding inputs✔ Deterministic input hashing✔ Separate embedding database model✔ Embedding lifecycle statuses✔ Batch generation✔ Token-aware batching✔ Vector validation✔ Optional vector normalization✔ Idempotent processing✔ Concurrency protection✔ Background embedding workers✔ Retry and backoff strategy✔ Partial-batch failure isolation✔ Confidentiality-aware provider routing✔ Tenant-scoped repositories✔ Token and cost tracking✔ Quota enforcement✔ Model versioning✔ Safe re-embedding strategy✔ Administrative APIs✔ React progress reporting✔ Audit logging✔ Operational metrics✔ Quality-evaluation preparation✔ Security controls✔ Comprehensive tests
The platform can now transform structured organizational knowledge into numerical semantic representations.
Recommended Git Commit
git add .git commit -m "Implement knowledge embedding generation pipeline"git push origin main
What We Will Build Next
The embedding pipeline can now generate validated vectors for every eligible knowledge chunk.
However, generating vectors is only part of the solution.
The platform still needs an efficient way to:
- Store high-dimensional vectors
- Index millions of embeddings
- Filter searches by organization
- Calculate vector distance
- Retrieve the nearest knowledge chunks
- Replace indexes during model migration
- Balance search quality and performance
The next article in the series is:
“Building the Vector Storage Layer with PostgreSQL and pgvector: Indexing Organizational Knowledge for Semantic Search.”
In that article, we will implement:
- The
pgvectorPostgreSQL extension - Vector columns
- Dimension-safe schema design
- Cosine, inner-product, and Euclidean operators
- Exact nearest-neighbor search
- HNSW indexes
- IVFFlat indexes
- Tenant-aware vector filtering
- Embedding-profile separation
- Index tuning
- Database migrations
- Repository queries
- Re-indexing workflows
- Performance benchmarking
- Backup and recovery considerations
- Vector storage tests
This vector storage layer will make organizational knowledge efficiently searchable by semantic meaning.
Conclusion
The Embedding Generation Pipeline transforms structured knowledge chunks into semantic vectors that allow the Tender Opportunity Scanner to compare meaning instead of relying only on exact words.
By introducing a provider-independent interface, the platform can use cloud-hosted, enterprise, or self-hosted embedding models without coupling the knowledge system to one vendor. Context-enriched embedding inputs ensure that document titles, categories, section paths, headings, and chunk content all contribute to the vector representation.
Batch processing, deterministic input hashes, idempotent jobs, concurrency protection, retry strategies, quota enforcement, and cost tracking make embedding generation reliable at production scale. Model and dimension metadata prevent incompatible vectors from being mixed, while versioned embedding profiles support controlled upgrades and rollback.
Security remains part of the design. Tenant boundaries, confidentiality-aware provider routing, private storage, restricted administrative actions, and safe deletion workflows protect both source content and derived vectors.
The platform can now create semantic representations of organizational knowledge. The next step is to store and index those vectors efficiently with PostgreSQL and pgvector, creating the retrieval foundation required for semantic search, hybrid search, and evidence-grounded proposal generation.