Vectorize + Workers AI Semantic Search

Priority: P1 (High Value)

What is Vectorize?

A globally distributed vector database for storing and querying embeddings. Combined with Workers AI for embedding generation, it enables semantic search, recommendations, and RAG without external services.

Why This Matters for Company Manager

Current Search Infrastructure

The platform uses \1 (tsvector, ts_rank) in the press center:


-- Current search pattern (packages/services/src/press-center/search-service.ts)
SELECT *, ts_rank(search_vector, to_tsquery('french', $1)) AS rank
FROM "Article"
WHERE search_vector @@ to_tsquery('french', $1)
  AND "tenantId" = $2
ORDER BY rank DESC

\1

What Vectorize + Workers AI Enables

FeatureBeforeAfter
Article searchKeyword matchingSemantic understanding
Product discoveryCategory browsing"Find products like this"
RecommendationsNoneAI-powered similar items
Support ticketsManual routingSemantic matching to solutions
Content moderationRule-basedEmbedding similarity detection
Multilingual searchPer-language indexCross-language vectors

Architecture


                   ┌──────────────────────────┐
                   │  Workers AI               │
                   │  (embedding generation)   │
                   │  @cf/baai/bge-base-en-v1.5│
                   └──────────┬───────────────┘
                              │ vectors
┌─────────────┐     ┌────────▼────────┐     ┌──────────────┐
│ Content      │────►│ Vectorize       │◄────│ Search Query │
│ (articles,   │     │ (vector store)  │     │ (user input) │
│  products,   │     │ 10M vectors/idx │     │              │
│  tickets)    │     │ multi-namespace  │     │              │
└─────────────┘     └─────────────────┘     └──────────────┘

Implementation

Step 1: Create Vectorize Index


# Article search index (384 dimensions for bge-small, 768 for bge-base)
npx wrangler vectorize create articles-index \
  --dimensions=768 \
  --metric=cosine

# Product recommendations index
npx wrangler vectorize create products-index \
  --dimensions=768 \
  --metric=cosine

# Support ticket similarity index
npx wrangler vectorize create tickets-index \
  --dimensions=768 \
  --metric=cosine

Step 2: Configure Worker


// wrangler.jsonc (search-worker)
{
  "name": "search-worker",
  "compatibility_flags": ["nodejs_compat"],
  "ai": { "binding": "AI" },
  "vectorize": [
    { "binding": "ARTICLES_INDEX", "index_name": "articles-index" },
    { "binding": "PRODUCTS_INDEX", "index_name": "products-index" },
    { "binding": "TICKETS_INDEX", "index_name": "tickets-index" }
  ]
}

Step 3: Embedding Generation + Indexing


// search-worker/src/index.ts

interface Env {
  AI: Ai;
  ARTICLES_INDEX: VectorizeIndex;
  PRODUCTS_INDEX: VectorizeIndex;
  TICKETS_INDEX: VectorizeIndex;
}

// Generate embedding for text
async function embed(env: Env, text: string): Promise<number[]> {
  const result = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
    text: [text],
  });
  return result.data[0]; // 768-dimensional vector
}

// Index an article
async function indexArticle(env: Env, article: Article) {
  const text = `${article.title} ${article.summary} ${article.content}`;
  const vector = await embed(env, text);

  await env.ARTICLES_INDEX.upsert([{
    id: article.id,
    values: vector,
    namespace: article.tenantId, // tenant isolation via namespace
    metadata: {
      title: article.title,
      author: article.author,
      publishedAt: article.publishedAt,
      category: article.category,
      tenantId: article.tenantId,
    },
  }]);
}

// Index a product
async function indexProduct(env: Env, product: Product) {
  const text = `${product.name} ${product.description} ${product.category}`;
  const vector = await embed(env, text);

  await env.PRODUCTS_INDEX.upsert([{
    id: product.id,
    values: vector,
    namespace: product.tenantId,
    metadata: {
      name: product.name,
      price: product.price,
      category: product.category,
      tenantId: product.tenantId,
    },
  }]);
}

Step 4: Semantic Search


// Semantic search endpoint
async function search(env: Env, query: string, tenantId: string, type: "articles" | "products") {
  const queryVector = await embed(env, query);

  const index = type === "articles" ? env.ARTICLES_INDEX : env.PRODUCTS_INDEX;

  const results = await index.query(queryVector, {
    topK: 20,
    namespace: tenantId,
    returnMetadata: "all",
    returnValues: false,
  });

  return results.matches.map(match => ({
    id: match.id,
    score: match.score,
    ...match.metadata,
  }));
}

Step 5: Similar Items / Recommendations


// "More like this" recommendations
async function findSimilar(env: Env, itemId: string, tenantId: string, type: "articles" | "products") {
  const index = type === "articles" ? env.ARTICLES_INDEX : env.PRODUCTS_INDEX;

  // Get the item's existing vector
  const existing = await index.getByIds([itemId]);
  if (!existing.length) return [];

  // Query for similar items (exclude self)
  const results = await index.query(existing[0].values!, {
    topK: 11, // extra 1 to exclude self
    namespace: tenantId,
    returnMetadata: "all",
  });

  return results.matches
    .filter(m => m.id !== itemId)
    .slice(0, 10);
}

Step 6: Hybrid Search (Semantic + Keyword)

Combine Vectorize results with PostgreSQL full-text for best results:


// In TRPC router -- hybrid search
export const searchRouter = createTRPCRouter({
  search: permissionProtectedProcedure(["content:read"])
    .input(z.object({ query: z.string(), type: z.enum(["articles", "products"]) }))
    .query(async ({ ctx, input }) => {
      // Parallel: semantic + keyword search
      const [semanticResults, keywordResults] = await Promise.all([
        // Semantic via Worker
        fetch(`${SEARCH_WORKER_URL}/search`, {
          method: "POST",
          body: JSON.stringify({
            query: input.query,
            tenantId: ctx.tenantId,
            type: input.type,
          }),
        }).then(r => r.json()),

        // Keyword via PostgreSQL (existing)
        getService("search", ctx).then(s => s.search(input.query)),
      ]);

      // Merge and re-rank (reciprocal rank fusion)
      return mergeResults(semanticResults, keywordResults);
    }),
});

Use Cases by Domain

1. Press Center Article Search

\1: tsvector keyword search \1: Semantic search + "related articles" + cross-language discovery


// User searches: "climate change impact on farming"
// Semantic: finds articles about agriculture, environment, sustainability
// Keyword: only finds articles with exact words "climate", "change", "farming"

2. Product Recommendations

\1: None (category browsing only) \1: "Similar products", "Customers also viewed", "Complete the look"


// User views: "Organic lavender essential oil"
// Recommends: other essential oils, aromatherapy products, organic skincare

3. Support Ticket Routing

\1: Manual routing by CustomerOperationsAgent \1: Match new tickets to resolved tickets for auto-suggestions


// New ticket: "My order hasn't arrived after 2 weeks"
// Finds similar resolved tickets with shipping delay solutions

4. AI Agent Enhancement

Enhance existing autonomy agents with vector context:


// ContentManagementAgent -- find content gaps
const existingContent = await env.ARTICLES_INDEX.query(topicVector, {
  topK: 5,
  namespace: tenantId,
});
// If low similarity scores → content gap → generate new content

5. Classified Ads Matching

Match buyer searches to seller listings semantically.

6. City Portal Entity Discovery

Semantic search across businesses and associations in city portal.

Multi-Tenant Isolation

Vectorize supports \1 (50K per index). Use tenantId as namespace:


// Tenant A's articles are isolated from Tenant B
await index.query(vector, {
  namespace: "tenant-uuid-a",  // only searches tenant A's vectors
  topK: 10,
});

This is perfect for Company Manager's multi-tenant architecture.

Embedding Model Options

ModelDimensionsSpeedQualityLanguages
`@cf/baai/bge-small-en-v1.5`384FastGoodEnglish
`@cf/baai/bge-base-en-v1.5`768MediumBetterEnglish
`@cf/baai/bge-large-en-v1.5`1024SlowBestEnglish
`@cf/baai/bge-m3`VariableMediumGood**Multilingual**
`@cf/google/embeddinggemma-300m`VariableFastGood**100+ languages**

\1: Use \1 or \1 for multilingual support (French + English content in Company Manager).

Batch Indexing Pipeline

For initial data load and ongoing sync:


// Batch indexing Worker (triggered by Queue)
export default {
  async queue(batch: MessageBatch<IndexJob>, env: Env) {
    const vectors: VectorizeVector[] = [];

    for (const message of batch.messages) {
      const { id, text, tenantId, metadata } = message.body;
      const embedding = await embed(env, text);
      vectors.push({ id, values: embedding, namespace: tenantId, metadata });
    }

    // Batch upsert (up to 1000 vectors)
    await env.INDEX.upsert(vectors);
  },
};

Limits

MetricFreePaid
Indexes/account10050,000
Vectors/index10M10M
Dimensions1,536 max1,536 max
Namespaces/index1,00050,000
Metadata/vector10 KiB10 KiB
topK (with metadata)2020
topK (without metadata)100100
Batch upsert1,000 vectors1,000 vectors

Pricing

Nearly free for moderate usage:

\1: 100K articles at 768 dimensions, 10K queries/day:

Estimated Impact