Overview
GTM Feedback uses vector embeddings to power semantic search across feature requests. This enables finding similar requests based on meaning rather than just keywords, helping identify duplicates and related feedback.
Architecture
The vector search system combines:
Upstash Vector - Serverless vector database for similarity search
OpenAI Embeddings - Generate 384-dimension text embeddings
AI SDK - Unified embedding generation interface
How It Works
Generate embedding
When a request is created, combine title and description into a single text representation.
Create vector
Use OpenAI’s text-embedding-3-small model to generate a 384-dimension embedding vector.
Store in Upstash
Upsert the vector into Upstash Vector with the request ID and metadata.
Search
Query with a new embedding to find the most similar requests by cosine similarity.
Upstash Vector Setup
Create a Vector Index
Sign up at Upstash Console
Create a new Vector index with:
Dimensions: 384 (matches OpenAI text-embedding-3-small)
Similarity Metric: Cosine
Region: Choose closest to your application
Environment Variables
Add to your .env file:
# Upstash Vector
UPSTASH_VECTOR_REST_URL = https://your-index-url.upstash.io
UPSTASH_VECTOR_REST_TOKEN = your_rest_token
# OpenAI for embeddings
OPENAI_API_KEY = sk-your-openai-api-key
Embedding Generation
The embeddings package is in packages/ai/src/embeddings/index.ts:
Single Embedding
packages/ai/src/embeddings/index.ts
import { createOpenAI } from "@ai-sdk/openai" ;
import { embed } from "ai" ;
export async function createRequestEmbedding (
title : string ,
description : string ,
apiKey : string ,
) : Promise < number [] | null > {
if ( ! apiKey ) {
console . error ( "OpenAI API key is required" );
return null ;
}
try {
const openai = createOpenAI ({ apiKey });
const text = ` ${ title } \n\n ${ description } ` ;
const { embedding } = await embed ({
model: openai . embeddingModel ( "text-embedding-3-small" ),
value: text ,
providerOptions: {
openai: {
dimensions: 384 , // Match Upstash Vector dimension
},
},
});
return embedding ;
} catch ( error ) {
console . error ( "Failed to create embedding:" , error );
return null ;
}
}
Batch Embeddings
For efficient processing of multiple requests:
packages/ai/src/embeddings/index.ts
import { embedMany } from "ai" ;
export async function createRequestEmbeddings (
items : Array <{ title : string ; description : string }>,
apiKey : string ,
) : Promise < number [][] | null > {
const openai = createOpenAI ({ apiKey });
const texts = items . map (( item ) => ` ${ item . title } \n\n ${ item . description } ` );
const { embeddings } = await embedMany ({
model: openai . embeddingModel ( "text-embedding-3-small" ),
values: texts ,
providerOptions: {
openai: { dimensions: 384 },
},
});
return embeddings ;
}
Use embedMany for batch processing - it’s more efficient than multiple individual calls.
Storing Vectors
Single Vector Storage
packages/ai/src/embeddings/index.ts
import { Index } from "@upstash/vector" ;
export async function storeRequestEmbedding (
requestId : string ,
embedding : number [],
url : string ,
token : string ,
metadata ?: Record < string , unknown >,
) : Promise < boolean > {
try {
const index = new Index ({
url ,
token ,
});
// Validate dimension
if ( embedding . length !== 384 ) {
console . error (
`Invalid embedding dimension: expected 384, got ${ embedding . length } `
);
return false ;
}
await index . upsert ({
id: requestId ,
vector: embedding ,
metadata: metadata || {},
});
return true ;
} catch ( error ) {
console . error ( `Failed to store embedding for ${ requestId } :` , error );
return false ;
}
}
Batch Vector Storage
packages/ai/src/embeddings/index.ts
export async function storeRequestEmbeddings (
items : Array <{
requestId : string ;
embedding : number [];
metadata ?: Record < string , unknown >;
}>,
url : string ,
token : string ,
) : Promise < Set < string >> {
const index = new Index ({ url , token });
const successfulIds = new Set < string >();
// Prepare vectors
const vectors = items . map (( item ) => ({
id: item . requestId ,
vector: item . embedding ,
metadata: item . metadata || {},
}));
// Upsert in parallel
const results = await Promise . allSettled (
vectors . map (( vector ) => index . upsert ( vector ))
);
// Track successful uploads
results . forEach (( result , idx ) => {
if ( result . status === "fulfilled" ) {
successfulIds . add ( vectors [ idx ]. id );
}
});
return successfulIds ;
}
Similarity Search
Finding Similar Requests
packages/ai/src/embeddings/index.ts
export async function findSimilarRequests (
embedding : number [],
url : string ,
token : string ,
limit : number = 10 ,
excludeIds : string [] = [],
) : Promise <
Array <{ id : string ; score : number ; metadata ?: Record < string , unknown > }>
> {
const index = new Index ({ url , token });
const results = await index . query ({
vector: embedding ,
topK: limit + excludeIds . length ,
includeMetadata: true ,
});
// Filter excluded IDs and limit results
return results
. filter (( result ) => ! excludeIds . includes ( String ( result . id )))
. slice ( 0 , limit )
. map (( result ) => ({
id: String ( result . id ),
score: result . score ,
metadata: result . metadata ,
}));
}
Search Scores
Upstash Vector returns cosine similarity scores between 0 and 1:
0.9 - 1.0 - Nearly identical requests
0.8 - 0.9 - Very similar, likely duplicates
0.7 - 0.8 - Related requests
< 0.7 - Different requests
Adjust similarity thresholds based on your use case and data characteristics.
Complete Workflow Example
Here’s a complete example of adding vector search to a request:
import {
createRequestEmbedding ,
storeRequestEmbedding ,
findSimilarRequests ,
} from "@feedback/ai/embeddings" ;
// 1. Create a new request
const request = {
id: "req_123" ,
title: "Add dark mode support" ,
description: "Users want the ability to toggle dark mode in the dashboard" ,
};
// 2. Generate embedding
const embedding = await createRequestEmbedding (
request . title ,
request . description ,
process . env . OPENAI_API_KEY !
);
if ( ! embedding ) {
throw new Error ( "Failed to generate embedding" );
}
// 3. Store in Upstash Vector
const stored = await storeRequestEmbedding (
request . id ,
embedding ,
process . env . UPSTASH_VECTOR_REST_URL ! ,
process . env . UPSTASH_VECTOR_REST_TOKEN ! ,
{
title: request . title ,
createdAt: new Date (). toISOString (),
}
);
if ( ! stored ) {
throw new Error ( "Failed to store embedding" );
}
// 4. Find similar requests
const similar = await findSimilarRequests (
embedding ,
process . env . UPSTASH_VECTOR_REST_URL ! ,
process . env . UPSTASH_VECTOR_REST_TOKEN ! ,
5 , // top 5 results
[ request . id ] // exclude self
);
console . log ( "Similar requests:" , similar );
// [
// { id: "req_456", score: 0.89, metadata: { ... } },
// { id: "req_789", score: 0.76, metadata: { ... } },
// ]
Updating and Deleting Vectors
Update Embedding
import { updateRequestEmbedding } from "@feedback/ai/embeddings" ;
// Generate new embedding
const newEmbedding = await createRequestEmbedding (
updatedTitle ,
updatedDescription ,
apiKey
);
// Update in Upstash (upsert handles updates)
await updateRequestEmbedding (
requestId ,
newEmbedding ,
vectorUrl ,
vectorToken ,
{ updatedAt: new Date (). toISOString () }
);
Delete Embedding
packages/ai/src/embeddings/index.ts
export async function deleteRequestEmbedding (
requestId : string ,
url : string ,
token : string ,
) : Promise < boolean > {
const index = new Index ({ url , token });
try {
await index . delete ([ requestId ]);
return true ;
} catch ( error ) {
console . error ( `Failed to delete embedding ${ requestId } :` , error );
return false ;
}
}
Batch Sync Workflow
Sync embeddings for all existing requests:
apps/www/src/workflows/sync-embeddings/index.ts
import { db } from "@feedback/db" ;
import {
createRequestEmbeddings ,
storeRequestEmbeddings ,
} from "@feedback/ai/embeddings" ;
// Fetch all requests without embeddings
const requests = await db . query . requests . findMany ({
where : ( requests , { isNull }) => isNull ( requests . embeddingId ),
});
// Generate embeddings in batch
const embeddings = await createRequestEmbeddings (
requests . map (( r ) => ({ title: r . title , description: r . description })),
process . env . OPENAI_API_KEY !
);
if ( ! embeddings ) {
throw new Error ( "Failed to generate embeddings" );
}
// Store all embeddings
const items = requests . map (( request , idx ) => ({
requestId: request . id ,
embedding: embeddings [ idx ],
metadata: {
title: request . title ,
status: request . status ,
},
}));
const successfulIds = await storeRequestEmbeddings (
items ,
process . env . UPSTASH_VECTOR_REST_URL ! ,
process . env . UPSTASH_VECTOR_REST_TOKEN !
);
console . log ( `Synced ${ successfulIds . size } / ${ requests . length } embeddings` );
Best Practices
Consistent Text Format Always combine title and description in the same format for comparable embeddings
Validate Dimensions Check embedding dimensions match your index (384) before upserting
Handle Errors Gracefully handle API failures and return null/empty results
Batch Operations Use batch functions for bulk operations to reduce API calls
Cost Optimization
Embedding Costs
text-embedding-3-small : $0.02 per 1M tokens
Average request: ~100 tokens
1000 requests : ~$0.002
Upstash Vector Costs
Free tier : 10,000 queries/day
Paid : Pay per query beyond free tier
Storage is included
Cache embeddings in your database to avoid regenerating them on every search.
Monitoring
Track embedding generation and search performance:
const startTime = Date . now ();
const embedding = await createRequestEmbedding ( title , description , apiKey );
const duration = Date . now () - startTime ;
console . log ( `Generated embedding in ${ duration } ms` );
AI Models Learn about OpenAI integration and AI SDK usage
Database Store embedding metadata in PostgreSQL