Skip to content

feat(categorization): semantic embedding tier + richer LLM prompts - #79

Open
gianniskotsas wants to merge 8 commits into
mainfrom
giannis/musing-wiles-cac256
Open

feat(categorization): semantic embedding tier + richer LLM prompts#79
gianniskotsas wants to merge 8 commits into
mainfrom
giannis/musing-wiles-cac256

Conversation

@gianniskotsas

@gianniskotsas gianniskotsas commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Embedding tier between deterministic keywords and the LLM: pgvector cosine ANN over category anchor embeddings built from name + description + keywords. Accepts only when similarity ≥ 0.78 AND margin ≥ 0.06 over runner-up; otherwise defers to LLM.
  • Richer LLM prompts: the model now sees each category's description and keyword hints (vendor lists like Jumbo / Cloudflare / Efood / IBKR). Previously only category names were sent — the curated keywords were invisible.
  • Smaller LLM batches (50 → 15) so each transaction gets a fair share of attention and prompts have room for hints.
  • Persisted metadata on transactions: categorization_confidence, categorization_method ('override' | 'deterministic' | 'embedding' | 'llm'), plus a cached embedding vector for retry idempotency.
  • Seed script for the four example categories (Side Projects, Groceries, Internal Transfer, Food & Dining).

Architecture

Pipeline now has four tiers:
user override → deterministic keywords → embedding semantic match → LLM fallback

Files

  • backend/app/services/category_embedding.py — new CategoryEmbeddingService
  • backend/app/services/category_matcher.py_format_category_list_with_hints helper; prompt updates; batch size default
  • backend/tasks/post_import_pipeline.py — embedding tier integrated into _batch_categorize_transactions
  • backend/app/models.py + frontend/lib/db/schema.ts — new columns
  • frontend/lib/db/migrations/0018_categorization_embeddings.manual.sql — pgvector extension, columns, HNSW index
  • backend/scripts/seed_category_keywords.py — pre-populate curated keyword lists

Deploy steps

  1. pip install -r backend/requirements.txt (adds pgvector>=0.3.0)
  2. Apply migration: psql $DATABASE_URL -f frontend/lib/db/migrations/0018_categorization_embeddings.manual.sql (or pnpm db:push)
  3. python -m scripts.seed_category_keywords <user_id> to backfill keywords and build anchor embeddings
  4. Next import run will use all four tiers and persist method + confidence per transaction

Test plan

  • Apply migration on a staging DB; confirm vector extension + new columns exist
  • Run seed script for a user; verify categories.embedding IS NOT NULL for the 4 seeded categories
  • Import a CSV containing a mix of vendor variants (e.g. "ALBERT HEIJN 1234", "AH to go AMSTERDAM") and confirm: (a) high-similarity ones are assigned via embedding tier (categorization_method = 'embedding'), (b) ambiguous ones fall through to LLM
  • Confirm persisted categorization_confidence roughly matches observed match quality
  • Existing unit tests for match_categories_batch_llm still pass

🤖 Generated with Claude Code


Summary by cubic

Adds a semantic embedding tier and richer LLM prompts to improve categorization accuracy and reduce LLM calls. We also persist method, confidence, and embeddings for auditability.

  • New Features

    • Embedding tier using pgvector cosine over category anchor embeddings; accept when similarity ≥ 0.78 and margin ≥ 0.06 over runner-up, else defer to LLM.
    • LLM prompts now include category descriptions and keyword hints (400-char truncation); batch size 50 → 15; integrated with the prompt-context budget and account context.
    • Pipeline: override → embedding → LLM, with accurate categorization_method; store categorization_confidence (0–100) and transaction embedding.
    • Embedding safety: vectors fixed at 1536 dims; pass dimensions only for text-embedding-3* and always length-check. Scripts: seed_category_keywords to add hints + rebuild anchors; backfill_transaction_embeddings to populate transactions.embedding only (ID-first, low-memory; positive-int flags).
  • Migration

    • Add pgvector and run migration 0018: categories.embedding; transactions.embedding, categorization_confidence, categorization_method; HNSW cosine index.
    • Deps/infra: install pgvector>=0.3.0; use Postgres image pgvector/pgvector:pg16 in CI/Compose; Drizzle vector(1536) custom type; frontend sets embedding: null on new categories.
    • Seed and refresh: python -m scripts.seed_category_keywords <user_id> then rebuild anchors; optional backfill: python -m scripts.backfill_transaction_embeddings <user_id>.

Written for commit a395ca5. Summary will update on new commits.

Review in cubic

Problem: LLM received only category NAMES, not descriptions or keywords —
vendor hints the user curated (Jumbo, Cloudflare, Efood, IBKR…) were
invisible. Batch size of 50 further diluted attention per line.

Changes:
- CategoryMatcher now renders each category as
  "- <name> — <description> | hints: <categorization_instructions>"
  in both single and batch LLM prompts, and instructs the model to
  tolerate spelling/spacing/accent variants. (category_matcher.py)
- Default LLM batch size 50 → 15 for tighter attention budget.
- New CategoryEmbeddingService: OpenAI text-embedding-3-small anchors on
  categories, pgvector cosine ANN lookup, accept when similarity ≥ 0.78
  AND margin ≥ 0.06 over runner-up; otherwise defer to LLM.
- Post-import pipeline now runs embedding tier before LLM and only hands
  uncertain transactions to the LLM, persisting method + confidence.
- Schema: pgvector extension + categories.embedding,
  transactions.{embedding,categorization_confidence,categorization_method}
  with HNSW cosine index on category anchors.
- seed_category_keywords.py pre-populates the four example categories
  (Side Projects, Groceries, Internal Transfer, Food & Dining) and
  rebuilds anchor embeddings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gianniskotsas
gianniskotsas requested a review from a team as a code owner April 24, 2026 12:06
@vercel

vercel Bot commented Apr 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
syllogic Ready Ready Preview, Comment May 31, 2026 6:59pm

… literal

- CI Postgres: swap postgres:16-alpine for pgvector/pgvector:pg16 so migration
  0018 can CREATE EXTENSION vector.
- category-manager.tsx: optimistic Category literal was missing the new
  embedding column; set it to null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="backend/tasks/post_import_pipeline.py">

<violation number="1" location="backend/tasks/post_import_pipeline.py:226">
P2: `categorization_method` is incorrectly hardcoded to `llm` for results that can come from user overrides.</violation>
</file>

<file name="backend/app/services/category_embedding.py">

<violation number="1" location="backend/app/services/category_embedding.py:115">
P1: Embedding output dimensions are not pinned to the database vector size, so changing the embedding model can generate incompatible vectors and break categorization writes/queries.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread backend/app/services/category_embedding.py
Comment thread backend/tasks/post_import_pipeline.py
…tor image

Review feedback from cubic-dev-ai:
- P1: Pass dimensions=EMBEDDING_DIMENSIONS to embeddings.create so swapping
  to a model with a different native size (e.g. text-embedding-3-large =
  3072 dims) can't silently corrupt the vector(1536) columns. Also guard
  with a post-call length check.
- P2: Restructure _batch_categorize_transactions into three explicit tiers
  (override → embedding → llm) so the persisted categorization_method is
  always accurate. Previously matches returned by match_categories_batch_llm
  could originate from user overrides but were tagged 'llm'.

Also: fix Compose Smoke Test by pointing POSTGRES_IMAGE at pgvector/pgvector:pg16
so the compose stack can CREATE EXTENSION vector during migrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds scripts/backfill_transaction_embeddings.py — populates the new
transactions.embedding column for existing rows. Does not touch
category_system_id, category_id, categorization_confidence, or
categorization_method. Existing categorizations stay exactly as-is.

Usage: python -m scripts.backfill_transaction_embeddings <user_id>
       [--dry-run] [--batch-size N] [--limit N]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="backend/scripts/backfill_transaction_embeddings.py">

<violation number="1" location="backend/scripts/backfill_transaction_embeddings.py:49">
P2: `q.all()` loads every matching transaction into memory, which can cause high memory usage for large backfills despite the batching loop.</violation>

<violation number="2" location="backend/scripts/backfill_transaction_embeddings.py:95">
P2: Validate `--batch-size` as a positive integer; otherwise invalid values can crash the script at runtime.</violation>
</file>

<file name="backend/app/services/category_embedding.py">

<violation number="1" location="backend/app/services/category_embedding.py:121">
P2: `dimensions` is sent unconditionally, but OpenAI only supports that parameter for `text-embedding-3*` models. If `CATEGORIZATION_EMBEDDING_MODEL` is set to `text-embedding-ada-002`, embedding requests fail and the service silently falls back by returning empty vectors.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread backend/scripts/backfill_transaction_embeddings.py Outdated
Comment thread backend/scripts/backfill_transaction_embeddings.py Outdated
Comment thread backend/app/services/category_embedding.py Outdated
- Backfill: validate --batch-size and --limit as positive ints via a custom
  argparse type; fetch only transaction IDs up front and load full rows in
  per-chunk queries, so very large backfills don't load every row into memory.
  expunge_all() between chunks to release SQLAlchemy identity map.
- Embedding service: only pass dimensions= when the model is
  text-embedding-3*. Older models (text-embedding-ada-002) would 400 if we
  sent that param, and the service would silently degrade to empty vectors.
  Post-call length validation retained to catch any mismatch either way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Main added a richer prompt context system (_compose_prompt_context with
budget degradation, account context for internal-transfer hints) that
overlapped with our keyword-hint work in _render_category_list.

Resolution:
- Keep main's budget system, account block, dynamic instructions block,
  and transfer_rule in both single and batch LLM call sites.
- Extend _render_category_list to also include categorization_instructions
  as "| hints: <...>" alongside description (truncated to 400 chars).
- Switch the batch LLM to use _render_category_list instead of
  name-only join, so the batch path gets keyword hints too.
- Keep our batch size reduction (50 → 15).

Our embedding tier, persisted categorization method/confidence, and
pgvector schema migration are unaffected by the merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gianniskotsas

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai these five issues have already been addressed in earlier commits on this branch:

  1. post_import_pipeline.py — categorization_method hardcoded to llm → fixed in 52dc736. The function is now structured as three explicit tiers (override / embedding / llm) and tags categorization_method correctly per tier (see lines 238, 246, 254 of the current file).
  2. category_embedding.py — dimensions not pinned → fixed in 52dc736 by passing dimensions=EMBEDDING_DIMENSIONS and validating returned vector length post-call.
  3. category_embedding.py — dimensions sent unconditionally → refined in ef529b3: only passed when EMBEDDING_MODEL.startswith('text-embedding-3'), so text-embedding-ada-002 etc. won't 400.
  4. backfill_transaction_embeddings.py — q.all() memory → fixed in ef529b3 by selecting only IDs upfront and batch-loading rows per chunk with expunge_all() between iterations.
  5. backfill_transaction_embeddings.py — --batch-size validation → fixed in ef529b3 via a custom _positive_int argparse type applied to both --batch-size and --limit.

Looks like the review re-scanned the original commits in the diff. No further action needed.

@cubic-dev-ai

cubic-dev-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

@cubic-dev-ai these five issues have already been addressed in earlier commits on this branch:

  1. post_import_pipeline.py — categorization_method hardcoded to llm → fixed in 52dc736. The function is now structured as three explicit tiers (override / embedding / llm) and tags categorization_method correctly per tier (see lines 238, 246, 254 of the current file).
  2. category_embedding.py — dimensions not pinned → fixed in 52dc736 by passing dimensions=EMBEDDING_DIMENSIONS and validating returned vector length post-call.
  3. category_embedding.py — dimensions sent unconditionally → refined in ef529b3: only passed when EMBEDDING_MODEL.startswith('text-embedding-3'), so text-embedding-ada-002 etc. won't 400.
    ...

@gianniskotsas You're right — looking at the current code state in the latest commits, all five of those issues have been addressed exactly as described.

It appears my previous feedback was based on an earlier iteration of the changes before those fixes were pushed.

  • Categorization Tiers: post_import_pipeline.py correctly tags the method as override, embedding, or llm based on which tier resolved the transaction.
  • Embedding Safety: CategoryEmbeddingService now correctly pins dimensions only for text-embedding-3 models and includes the post-call length check to prevent DB mismatches.
  • Memory Efficiency: The backfill script's ID-first approach and use of expunge_all() properly protects against OOM issues on large datasets.
  • Input Validation: The _positive_int argparse type ensures valid runtime flags.

The implementation looks solid and follows the requested best practices. No further action is needed.

…cac256

# Conflicts:
#	frontend/lib/db/schema.ts
…cac256

# Conflicts:
#	backend/requirements.txt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant