← All posts
·15 min read

Claude Code with Weaviate: Hybrid Search That Actually Works

Claude CodeWeaviateVector DatabaseHybrid Search
Claude Code with Weaviate: hybrid search and vector database

Why Weaviate without CLAUDE.md becomes an expensive Elasticsearch

Weaviate is a vector database with first-class hybrid search, where you combine semantic similarity with BM25 keyword scoring in a single query. The architecture is column-oriented, multi-tenant by default, and supports named vectors so a single object can be searched along multiple embedding spaces (a product searched by description, image, and review text from the same collection). The product is genuinely good at this. The catch is that none of those advantages show up unless you design the schema with them in mind and configure the search parameters correctly.

Claude Code, without explicit instructions, generates Weaviate integrations that produce a working Document class with a text property and a single vector, then calls nearText for every search. That works. It also throws away most of what makes Weaviate different from a generic vector store. There is no BM25 fallback for queries the embedding cannot understand (like exact product SKU lookup), no hybrid score fusion, no multi-tenant isolation if you ever sell the product to multiple customers, and no cross-references so the database understands that a Chunk belongs to a Document belongs to a Project. The result is a vector store that works for the demo and falls over the moment a real customer asks "find me the document where Q3 2026 revenue was $1.2M" and the embedding similarity returns junk because the model has no idea what your specific company's revenue numbers are.

This guide covers the CLAUDE.md configuration that locks Claude Code into Weaviate's correct model: schema designed with named vectors for the search modalities you actually need, hybrid search as the default with alpha tuned per-query-type, multi-tenancy enabled at schema creation (not bolted on later), batch imports with the gRPC client for ingestion, and explicit reference properties between related classes. If you are building a RAG pipeline, Claude Code with the Anthropic SDK covers the Claude side of the retrieval-augmented generation loop. For Postgres-side metadata that pairs with Weaviate's vector data, Claude Code with Drizzle covers the relational layer.

The Weaviate CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Weaviate integration it needs to declare: the client SDK version, the connection method (gRPC for ingestion, GraphQL for query), the schema design rules, the vectoriser configuration, the multi-tenancy posture, the hybrid search defaults, and the hard rules that prevent the schema regrets Claude generates most often.

# Weaviate vector database rules

## Stack
- weaviate-client ^4.x (Python) or weaviate-client ^3.x (Node/TS)
- Weaviate server v1.27+ (named vectors, multi-tenancy, gRPC)
- Connection: gRPC for ingestion, HTTP/GraphQL for queries
- WEAVIATE_URL in .env (e.g. https://my-cluster.weaviate.network)
- WEAVIATE_API_KEY in .env (never hardcode)
- VECTORISER_API_KEY (OpenAI/Cohere/etc) if using managed vectorisation

## Project structure
- src/lib/weaviate.ts          , Client singleton
- src/lib/schema/              , One file per collection (Document.ts, Chunk.ts)
- src/lib/schema/migrate.ts    , Idempotent schema sync script
- src/lib/search/              , Search functions (one per query type)
- src/lib/ingest/              , Batch import scripts

## Schema rules (MANDATORY)

### Vectoriser
- Specify vectoriser per collection, NEVER rely on the cluster default
- For RAG: text2vec-openai or text2vec-cohere (specify model + dimensions)
- For BYO embeddings: vectorizer: "none" with explicit vector dimensions
- NEVER mix managed and BYO vectorisation in the same collection

### Named vectors
- Use named vectors when an object has multiple embedding modalities
- Example: Product has "description_vector" + "image_vector" + "review_vector"
- Each named vector has its own vectoriser config
- NEVER use the default vector if any object has more than one modality

### Properties
- Properties are typed: text | int | number | boolean | date | uuid | blob | text[] | geoCoordinates
- Indexable BM25 properties: set indexFilterable: true and indexSearchable: true
- High-cardinality non-search properties: indexFilterable: true only
- Internal metadata: indexFilterable: false (skip the inverted index)

### References (cross-references)
- Use references to model relationships: Chunk -> Document -> Project
- ALWAYS specify the target class explicitly
- NEVER store the parent ID as a string property when a reference would model it correctly

## Multi-tenancy
- Enable multi-tenancy at schema creation: multiTenancyConfig: { enabled: true }
- Tenant ID convention: "{customer_id}" (NEVER user-provided strings)
- ALWAYS pass tenant: in every query and every insert
- NEVER query across tenants in application code

## Hybrid search defaults
- Default fusionType: rankedFusion
- Default alpha: 0.5 (50% vector, 50% BM25)
- Tune alpha per query type:
  - Exact lookup (SKU, name): alpha 0.0 (BM25 only)
  - Semantic question: alpha 0.75 (heavy vector)
  - Mixed conversational: alpha 0.5

## Hard rules
- NEVER create a collection without explicit vectoriser config
- NEVER skip multi-tenancy at schema creation (cannot be enabled later)
- NEVER call insertMany without setting consistencyLevel: "QUORUM" for production data
- NEVER use HTTP for bulk imports (use gRPC, 5-10x faster)
- NEVER hardcode the vectoriser model version, capture it in schema metadata
- ALWAYS test schema changes against a staging tenant first

Three rules here prevent the majority of production regrets Claude generates without them.

The multi-tenancy at schema creation rule is the most impactful for any application that might ever serve more than one customer. Weaviate multi-tenancy enables physical isolation of tenant data, with separate shards per tenant and the ability to offload inactive tenants to cold storage. None of this can be added to an existing collection: once a collection is created without multi-tenancy, the migration path is a full reindex into a new collection. Claude defaults to non-multi-tenant because it is simpler, then a future product decision adds the second customer and the engineering team spends a quarter migrating.

The named vectors rule prevents the most common schema regret in RAG applications. Documents have multiple meaningful representations: title embedding, body embedding, summary embedding. Search quality differs dramatically based on which representation you query against. Claude defaults to a single vector per object because the quickstart docs show that pattern. Six months in, the team realises the title embedding would have produced better results for navigation queries and the body embedding for content queries, but they cannot retroactively split them without a reindex.

The explicit vectoriser config rule counters Claude's tendency to rely on the cluster default. Cluster defaults change between Weaviate versions. Capturing the vectoriser, model, and dimensions in the schema makes the choice explicit and reproducible. It also makes upgrades safe: when Weaviate introduces a new vectoriser version, your schema still pins the version that produced your existing embeddings.

Install and client setup

Install the Weaviate client:

# Python
pip install weaviate-client

# Node/TypeScript
npm i weaviate-client

Set up the environment variables:

# .env
WEAVIATE_URL=https://your-cluster.weaviate.network
WEAVIATE_API_KEY=your_api_key_here
OPENAI_API_KEY=sk-...  # if using text2vec-openai

Create the singleton client:

// src/lib/weaviate.ts
import weaviate, { WeaviateClient } from 'weaviate-client';

if (!process.env.WEAVIATE_URL || !process.env.WEAVIATE_API_KEY) {
  throw new Error('WEAVIATE_URL and WEAVIATE_API_KEY are required');
}

let cachedClient: WeaviateClient | null = null;

export async function getWeaviate(): Promise<WeaviateClient> {
  if (cachedClient) return cachedClient;

  cachedClient = await weaviate.connectToWeaviateCloud(process.env.WEAVIATE_URL!, {
    authCredentials: new weaviate.ApiKey(process.env.WEAVIATE_API_KEY!),
    headers: {
      'X-OpenAI-Api-Key': process.env.OPENAI_API_KEY ?? '',
    },
  });

  return cachedClient;
}

Add the singleton pattern to CLAUDE.md so Claude never instantiates inline:

## Client singleton (ENFORCE)
- Single client returned by getWeaviate() in src/lib/weaviate.ts
- Every file that queries does: const client = await getWeaviate()
- Claude MUST NOT write: weaviate.connectToWeaviateCloud(...) inside route handlers

Schema design

A well-designed Weaviate schema models your domain, not your storage. For a typical RAG application over multi-customer knowledge bases, the schema has three classes: Project (tenant boundary), Document (a file or web page), Chunk (the searchable unit).

// src/lib/schema/document.ts
import { getWeaviate } from '@/lib/weaviate';
import { configure } from 'weaviate-client';

export async function ensureDocumentCollection() {
  const client = await getWeaviate();

  const exists = await client.collections.exists('Document');
  if (exists) return;

  await client.collections.create({
    name: 'Document',
    description: 'A source document ingested into the knowledge base.',
    multiTenancy: configure.multiTenancy({ enabled: true, autoTenantCreation: false }),
    vectorizers: configure.vectorizer.text2VecOpenAI({
      name: 'title_vector',
      sourceProperties: ['title'],
      model: 'text-embedding-3-small',
      dimensions: 1536,
    }),
    properties: [
      { name: 'project_id', dataType: 'text', indexFilterable: true, indexSearchable: false },
      { name: 'title', dataType: 'text', indexFilterable: true, indexSearchable: true },
      { name: 'source_url', dataType: 'text', indexFilterable: true, indexSearchable: false },
      { name: 'created_at', dataType: 'date', indexFilterable: true },
      { name: 'mime_type', dataType: 'text', indexFilterable: true, indexSearchable: false },
    ],
  });
}

The Chunk collection has two named vectors (content and a question-rewritten version) and a reference to the parent document:

// src/lib/schema/chunk.ts
import { getWeaviate } from '@/lib/weaviate';
import { configure, dataType } from 'weaviate-client';

export async function ensureChunkCollection() {
  const client = await getWeaviate();

  const exists = await client.collections.exists('Chunk');
  if (exists) return;

  await client.collections.create({
    name: 'Chunk',
    description: 'A searchable chunk of a document.',
    multiTenancy: configure.multiTenancy({ enabled: true, autoTenantCreation: false }),
    vectorizers: [
      configure.vectorizer.text2VecOpenAI({
        name: 'content_vector',
        sourceProperties: ['content'],
        model: 'text-embedding-3-small',
        dimensions: 1536,
      }),
      configure.vectorizer.text2VecOpenAI({
        name: 'question_vector',
        sourceProperties: ['hypothetical_questions'],
        model: 'text-embedding-3-small',
        dimensions: 1536,
      }),
    ],
    properties: [
      { name: 'content', dataType: 'text', indexFilterable: true, indexSearchable: true },
      { name: 'hypothetical_questions', dataType: 'text', indexFilterable: false, indexSearchable: true },
      { name: 'chunk_index', dataType: 'int', indexFilterable: true },
      { name: 'token_count', dataType: 'int', indexFilterable: true },
      { name: 'page_number', dataType: 'int', indexFilterable: true },
    ],
    references: [
      { name: 'parent_document', targetCollection: 'Document' },
    ],
  });
}

Run schema migrations in an idempotent script:

// src/lib/schema/migrate.ts
import { ensureDocumentCollection } from './document';
import { ensureChunkCollection } from './chunk';
import { ensureProjectCollection } from './project';

export async function migrateSchema() {
  // Order matters: parent collections before children with references
  await ensureProjectCollection();
  await ensureDocumentCollection();
  await ensureChunkCollection();
  console.log('Weaviate schema is current.');
}

if (import.meta.url === `file://${process.argv[1]}`) {
  migrateSchema().catch(err => {
    console.error('[schema] migration failed:', err);
    process.exit(1);
  });
}

Add a schema migration section to CLAUDE.md:

## Schema migration

- One file per collection at src/lib/schema/{Name}.ts
- Each file exports ensure{Name}Collection() that is idempotent
- migrate.ts orchestrates the order (parents before children)
- Run via: npm run migrate:weaviate
- NEVER drop and recreate a collection in production
- For breaking changes: create a new collection, migrate data, switch reads, then drop old

Multi-tenancy

Multi-tenancy is enabled at collection creation and used in every query and insert. The tenant ID is your customer or organisation identifier. Weaviate isolates each tenant's data into a separate shard.

Creating a tenant when a customer signs up:

// src/lib/tenants.ts
import { getWeaviate } from '@/lib/weaviate';

export async function ensureTenant(projectId: string) {
  const client = await getWeaviate();
  const collections = ['Project', 'Document', 'Chunk'];

  for (const name of collections) {
    const collection = client.collections.get(name);
    const tenants = await collection.tenants.get();
    const exists = Object.keys(tenants).includes(projectId);

    if (!exists) {
      await collection.tenants.create([{ name: projectId }]);
    }
  }
}

Querying within a tenant:

// src/lib/search/hybrid.ts
import { getWeaviate } from '@/lib/weaviate';

interface HybridSearchOptions {
  projectId: string;
  query: string;
  alpha?: number;
  limit?: number;
  targetVector?: 'content_vector' | 'question_vector';
}

export async function hybridSearchChunks(opts: HybridSearchOptions) {
  const client = await getWeaviate();
  const chunks = client.collections.get('Chunk').withTenant(opts.projectId);

  const results = await chunks.query.hybrid(opts.query, {
    alpha: opts.alpha ?? 0.5,
    targetVector: opts.targetVector ?? 'content_vector',
    limit: opts.limit ?? 10,
    returnMetadata: ['score', 'explainScore'],
    returnReferences: [{ linkOn: 'parent_document', returnProperties: ['title', 'source_url'] }],
  });

  return results.objects.map(obj => ({
    content: obj.properties.content,
    score: obj.metadata?.score,
    document: obj.references?.parent_document.objects[0]?.properties,
  }));
}

Add a multi-tenancy section to CLAUDE.md:

## Multi-tenancy patterns

- Tenant is created when a Project is created in the application database
- ensureTenant(projectId) is idempotent, safe to call on every project read
- Every collection access does .withTenant(projectId) before .query or .insert
- NEVER pass user input directly as the tenant ID, use a server-side projectId
- For cross-tenant aggregate queries: use the Weaviate admin client with a different code path

Hybrid search and alpha tuning

Hybrid search combines vector similarity with BM25 keyword scoring. The alpha parameter blends the two: alpha=0.0 is pure BM25, alpha=1.0 is pure vector, alpha=0.5 is balanced. The right alpha depends on the query type.

Three patterns to encode:

// src/lib/search/router.ts
type QueryIntent = 'exact_lookup' | 'semantic_question' | 'mixed';

function detectIntent(query: string): QueryIntent {
  // Exact lookups: short, contains identifier-like tokens
  if (query.length < 30 && /[A-Z0-9]{4,}/.test(query)) return 'exact_lookup';
  // Questions: starts with WH-word or has question mark
  if (/^(what|how|when|where|why|who|which)\b/i.test(query) || query.endsWith('?')) {
    return 'semantic_question';
  }
  return 'mixed';
}

const ALPHA_BY_INTENT: Record<QueryIntent, number> = {
  exact_lookup: 0.1,
  semantic_question: 0.75,
  mixed: 0.5,
};

export function alphaForQuery(query: string): number {
  return ALPHA_BY_INTENT[detectIntent(query)];
}

Add a hybrid search section to CLAUDE.md:

## Hybrid search

- ALWAYS use hybrid, never pure nearText or pure BM25
- Alpha defaults by intent:
  - exact_lookup (SKU, name, identifier): 0.1
  - semantic_question (natural language, WH-question): 0.75
  - mixed (conversational, ambiguous): 0.5
- fusionType: "rankedFusion" (default), switch to "relativeScoreFusion" only when BM25 scores are dominating
- Always returnMetadata: ['score'] so the app can filter low-confidence results

## Score thresholds
- Hybrid scores in rankedFusion mode are 1/rank, max ~1.0
- Useful threshold: filter results below 0.3 for typical RAG use
- For low-recall query types: lower threshold to 0.15 and let the LLM filter

For the RAG side that consumes these chunks, Claude Code with the Anthropic SDK covers the message shape that wraps retrieved chunks into Claude's context.

Batch ingestion

The biggest performance gap between Claude's default Weaviate code and production-grade ingestion is the use of batch imports over the gRPC client. Single-object inserts max out at a few hundred per second. Batch imports over gRPC reach tens of thousands.

// src/lib/ingest/chunks.ts
import { getWeaviate } from '@/lib/weaviate';
import type { Chunk } from '@/types';

interface ChunkRecord {
  id: string;
  projectId: string;
  documentUuid: string;
  content: string;
  hypotheticalQuestions: string;
  chunkIndex: number;
  tokenCount: number;
  pageNumber: number;
}

export async function ingestChunks(records: ChunkRecord[]) {
  if (records.length === 0) return;
  const client = await getWeaviate();
  const collection = client.collections.get('Chunk');

  // Group by tenant
  const byTenant = new Map<string, ChunkRecord[]>();
  for (const r of records) {
    if (!byTenant.has(r.projectId)) byTenant.set(r.projectId, []);
    byTenant.get(r.projectId)!.push(r);
  }

  for (const [tenant, tenantRecords] of byTenant) {
    const tenantCollection = collection.withTenant(tenant);

    const result = await tenantCollection.data.insertMany(
      tenantRecords.map(r => ({
        id: r.id,
        properties: {
          content: r.content,
          hypothetical_questions: r.hypotheticalQuestions,
          chunk_index: r.chunkIndex,
          token_count: r.tokenCount,
          page_number: r.pageNumber,
        },
        references: {
          parent_document: [r.documentUuid],
        },
      })),
    );

    if (result.hasErrors) {
      const errorCount = Object.keys(result.errors ?? {}).length;
      console.error(`[ingest] ${errorCount} chunk errors for tenant ${tenant}`);
      throw new Error(`Batch insert failed with ${errorCount} errors`);
    }
  }
}

Add a batch ingestion section to CLAUDE.md:

## Batch ingestion

- Use collection.data.insertMany() for all imports of more than 10 objects
- Group by tenant before insertMany (one call per tenant)
- Batch size: 100-500 objects per insertMany call
- Larger batches: chunk into 500-object batches with a 100ms pause between
- ALWAYS check result.hasErrors and surface the count
- Generate IDs deterministically (e.g. uuidv5 from source URL + chunk index) for idempotent re-ingestion

## Hard rules for ingestion
- NEVER call data.insert() in a loop (use insertMany)
- NEVER skip the consistencyLevel parameter for cross-region clusters
- NEVER ingest without first verifying the tenant exists
- NEVER assume the server vectoriser is fast, set vectoriser timeout to 30s

Search with filters

Hybrid search combined with filters is the common production pattern: "search within this project, filter by document type, then hybrid search the chunks".

// src/lib/search/filtered.ts
import { getWeaviate } from '@/lib/weaviate';
import { Filters } from 'weaviate-client';

interface FilteredSearchOptions {
  projectId: string;
  query: string;
  mimeTypes?: string[];
  minDate?: Date;
  limit?: number;
}

export async function filteredHybridSearch(opts: FilteredSearchOptions) {
  const client = await getWeaviate();
  const chunks = client.collections.get('Chunk').withTenant(opts.projectId);

  const filters: ReturnType<typeof Filters.and>[] = [];

  if (opts.mimeTypes && opts.mimeTypes.length > 0) {
    filters.push(
      Filters.byRef('parent_document').byProperty('mime_type').containsAny(opts.mimeTypes),
    );
  }

  if (opts.minDate) {
    filters.push(
      Filters.byRef('parent_document').byProperty('created_at').greaterThan(opts.minDate),
    );
  }

  const results = await chunks.query.hybrid(opts.query, {
    alpha: 0.5,
    targetVector: 'content_vector',
    limit: opts.limit ?? 10,
    filters: filters.length > 0 ? Filters.and(...filters) : undefined,
    returnMetadata: ['score'],
    returnReferences: [{ linkOn: 'parent_document', returnProperties: ['title', 'source_url'] }],
  });

  return results.objects;
}

Filter operations using reference properties (Filters.byRef('parent_document').byProperty(...)) are the cleanest way to model "find chunks where the parent document has property X". The alternative (duplicating parent fields onto every chunk) bloats storage and creates update consistency problems.

Common Claude Code mistakes with Weaviate

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Multi-tenancy disabled at schema creation

Claude generates: await collections.create({ name: 'Document', properties: [...] }) with no multi-tenancy config.

Correct pattern: every collection in a customer-facing product has multiTenancy: configure.multiTenancy({ enabled: true }). This cannot be added later.

2. Single vector when multiple are needed

Claude generates: one default vector per object, sourced from a concatenated string.

Correct pattern: named vectors per modality (title, content, summary), each with its own source properties.

3. Pure nearText queries instead of hybrid

Claude generates: await chunks.query.nearText(query, { limit: 10 }).

Correct pattern: await chunks.query.hybrid(query, { alpha: alphaForQuery(query), limit: 10 }).

4. Loop-based inserts

Claude generates: for (const chunk of chunks) { await collection.data.insert({...}); }.

Correct pattern: await collection.data.insertMany(chunks.map(c => ({...}))) with batches of 100-500.

5. Parent ID as a string property instead of a reference

Claude generates: { name: 'document_id', dataType: 'text' } on the Chunk class.

Correct pattern: references: [{ name: 'parent_document', targetCollection: 'Document' }] with reference-property filters at query time.

6. Vectoriser model unspecified

Claude generates: vectorizer: 'text2vec-openai' with no model or dimensions.

Correct pattern: text2VecOpenAI({ model: 'text-embedding-3-small', dimensions: 1536 }). The model is pinned in schema so upgrades do not silently change embedding semantics.

Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.

Permission hooks for Weaviate scripts

A Weaviate integration accumulates scripts: schema migration, batch ingestion, tenant creation, dataset export, collection cleanup. Some are read-only. Some create or destroy data permanently. Permission hooks gate the destructive ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(npm run migrate:weaviate*)",
      "Bash(node scripts/ingest-batch.js*)",
      "Bash(node scripts/list-tenants.js*)",
      "Bash(node scripts/export-collection.js*)"
    ],
    "deny": [
      "Bash(node scripts/drop-collection.js*)",
      "Bash(node scripts/purge-tenant.js*)",
      "Bash(node scripts/reset-cluster.js*)"
    ]
  }
}

Schema migration is idempotent and safe. Ingesting a batch is safe (records are upsert-by-id). Dropping a collection or purging a tenant deletes vectors and properties permanently with no rollback. The deny list forces Claude to surface those operations as prompts.

Building hybrid search that survives production traffic

The Weaviate CLAUDE.md in this guide produces vector database setups where multi-tenancy is enabled at schema creation, named vectors capture multiple search modalities per object, hybrid search is the default with alpha tuned by query intent, references model the actual relationships in your domain, batch imports run over gRPC with proper consistency levels, and the vectoriser model is pinned in schema so embeddings never silently shift.

The underlying principle is the same as any data-platform integration with Claude Code. Weaviate without a CLAUDE.md produces code that works for the demo but fails the first time a real customer asks a question the vector similarity cannot answer, or the first time the product adds a second tenant. The CLAUDE.md template removes each failure mode by making the multi-tenant, hybrid, named-vector pattern the only pattern Claude can generate.

For the next layer of the stack, Weaviate pairs with Claude Code with Drizzle for the Postgres-side metadata that complements the vector data, and Claudify includes a Weaviate-specific CLAUDE.md template with schema patterns, hybrid search defaults, multi-tenancy rules, ingestion patterns, and all six common-mistake rules pre-configured.

Get Claudify. Ship Weaviate schemas that survive year-two growth.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame