llm-join 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,9 @@ on:
7
7
  pull_request:
8
8
  branches: [ "main" ]
9
9
 
10
+ env:
11
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
12
+
10
13
  jobs:
11
14
  lint:
12
15
  runs-on: ubuntu-latest
@@ -27,7 +30,8 @@ jobs:
27
30
  run: |
28
31
  python -m pip install --upgrade pip
29
32
  pip install flake8
30
- pip install -e ".[dev]"
33
+ pip install -e .
34
+ pip install pytest pytest-asyncio
31
35
 
32
36
  - name: Lint with flake8
33
37
  run: |
@@ -1,16 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llm-join
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Fuzzy join DataFrames using LLM scoring and embedding retrieval
5
5
  License: MIT
6
6
  Keywords: entity-resolution,fuzzy-join,llm,nlp,pandas
7
7
  Requires-Python: >=3.9
8
8
  Requires-Dist: faiss-cpu>=1.7
9
- Requires-Dist: numpy>=1.23
9
+ Requires-Dist: numpy<2,>=1.23
10
10
  Requires-Dist: pandas>=1.5
11
- Provides-Extra: dev
12
- Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
13
- Requires-Dist: pytest>=7; extra == 'dev'
14
11
  Provides-Extra: sentence-transformers
15
12
  Requires-Dist: sentence-transformers>=2.2; extra == 'sentence-transformers'
16
13
  Description-Content-Type: text/markdown
@@ -21,15 +18,21 @@ Description-Content-Type: text/markdown
21
18
 
22
19
  `pd.merge` joins on exact values. `llm-join` joins on *meaning* — using embeddings to find candidates and an LLM you already have to decide if they match.
23
20
 
21
+ ```python
22
+ from llm_join import fuzzy_join
23
+
24
+ result = fuzzy_join(df1, df2, left_on="vendor", right_on="supplier_name", llm=my_llm, embed_fn=my_embed, context="...")
25
+ ```
26
+
24
27
  ---
25
28
 
26
29
  ## Table of Contents
27
30
 
28
31
  - [The Problem](#the-problem)
29
- - [Install](#install)
30
- - [Quick Start](#quick-start)
31
32
  - [Why llm-join](#why-llm-join)
32
33
  - [Real-World Use Cases](#real-world-use-cases)
34
+ - [Install](#install)
35
+ - [Quick Start](#quick-start)
33
36
  - [How It Works](#how-it-works)
34
37
  - [Cost & Scale](#cost--scale)
35
38
  - [The problem with naive LLM joins](#the-problem-with-naive-llm-joins)
@@ -47,16 +50,10 @@ Description-Content-Type: text/markdown
47
50
  - [Chaining multiple joins](#chaining-multiple-joins)
48
51
  - [Works with Any LLM](#works-with-any-llm)
49
52
  - [Works with Any Embedding Function](#works-with-any-embedding-function)
50
- - [vs. Alternatives](#vs-alternatives)
53
+ - [Features](#features)
51
54
  - [Parameters](#parameters)
52
55
  - [License](#license)
53
56
 
54
- ```python
55
- from llm_join import fuzzy_join
56
-
57
- result = fuzzy_join(df1, df2, left_on="vendor", right_on="supplier_name", llm=my_llm, embed_fn=my_embed)
58
- ```
59
-
60
57
  ---
61
58
 
62
59
  ## The Problem
@@ -76,19 +73,52 @@ You have two DataFrames. Same data, different text:
76
73
 
77
74
  ---
78
75
 
76
+ ## Why llm-join
77
+
78
+ ### vs. `pd.merge`
79
+ Exact string match only. Fails on any variation in naming.
80
+
81
+ ### vs. fuzzy string matching (`fuzzywuzzy`, `rapidfuzz`)
82
+ Character similarity, not semantic meaning. `"iPhone 14 Pro"` vs `"iPhone 14 Pro Max"` scores high — but they are different products. `"CABLE-USBC-200CM-BLK"` vs `"USB-C charging cable 2m black"` scores near zero — even though they are the same item.
83
+
84
+ ### vs. embedding similarity alone
85
+ Fast and cheap, but no reasoning. Can't explain *why* two values match or catch false positives confidently.
86
+
87
+ ### llm-join
88
+ Embeddings narrow down candidates (fast, cheap). LLM makes the final call with context (accurate). You get the best of both.
89
+
90
+ ---
91
+
92
+ ## Real-World Use Cases
93
+
94
+ | Domain | Left table | Right table | Problem |
95
+ |--------|-----------|-------------|---------|
96
+ | **Supply chain** | Buyer product description | Supplier SKU | Match products across 50+ vendor catalogs |
97
+ | **Finance** | Expense report payee | GL account / vendor master | Reconcile transactions automatically |
98
+ | **Legal / M&A** | Contract party name | Corporate registry | Identify true legal entity |
99
+ | **Compliance** | Customer name | OFAC sanctions list | Sanctions screening at scale |
100
+ | **Retail / e-commerce** | Marketplace product listing | Master product catalog | Deduplicate listings across 50+ sellers |
101
+ | **Logistics** | Shipment description | Harmonized tariff code | Auto-classify goods at customs |
102
+ | **Research** | Author name | Citation database | Disambiguate authors |
103
+ | **Government** | Vendor name | Tax registry | Consolidate procurement spend |
104
+ | **Real estate** | Raw address input | Property records DB | Standardize and match addresses |
105
+
106
+ ---
107
+
79
108
  ## Install
80
109
 
81
110
  ```bash
82
- # From GitHub (not yet on PyPI)
83
- pip install git+https://github.com/adityabalki/llm-join.git
111
+ pip install llm-join
112
+ ```
84
113
 
85
- # Or clone and install locally
114
+ ```bash
115
+ # Or install from source
86
116
  git clone https://github.com/adityabalki/llm-join.git
87
117
  cd llm-join
88
118
  pip install -e .
89
119
 
90
- # Or copy the wheel to air-gapped machines
91
- pip install llm_join-0.1.0-py3-none-any.whl
120
+ # Or install from wheel (air-gapped machines)
121
+ pip install llm_join-0.2.0-py3-none-any.whl
92
122
  ```
93
123
 
94
124
  ---
@@ -97,10 +127,10 @@ pip install llm_join-0.1.0-py3-none-any.whl
97
127
 
98
128
  ```python
99
129
  import pandas as pd
130
+ import numpy as np
100
131
  import openai
101
132
  from llm_join import fuzzy_join
102
133
 
103
- # Your data
104
134
  df1 = pd.DataFrame({
105
135
  "vendor": ["Goldman Sachs & Co.", "Amazon Web Services", "Microsoft Corp"],
106
136
  "spend": [1_200_000, 890_000, 340_000]
@@ -111,26 +141,23 @@ df2 = pd.DataFrame({
111
141
  "category": ["Finance", "Cloud", "Software"]
112
142
  })
113
143
 
114
- # Wire up any LLM you already use
115
144
  client = openai.OpenAI()
116
- def llm(prompt):
145
+
146
+ def my_llm(prompt):
117
147
  return client.chat.completions.create(
118
148
  model="gpt-4o-mini",
119
149
  messages=[{"role": "user", "content": prompt}]
120
150
  ).choices[0].message.content
121
151
 
122
- # Wire up your embedding function
123
- import numpy as np
124
152
  def my_embed(texts):
125
153
  response = client.embeddings.create(model="text-embedding-3-small", input=texts)
126
154
  return np.array([d.embedding for d in response.data], dtype="float32")
127
155
 
128
- # Join (inner by default — only rows that matched)
129
156
  result = fuzzy_join(
130
157
  df1, df2,
131
158
  left_on="vendor",
132
159
  right_on="supplier_name",
133
- llm=llm,
160
+ llm=my_llm,
134
161
  embed_fn=my_embed,
135
162
  context="company names — match legal entity variants and abbreviations",
136
163
  how="inner", # "inner" | "left" | "right" | "outer"
@@ -147,39 +174,6 @@ print(result)
147
174
 
148
175
  ---
149
176
 
150
- ## Why llm-join
151
-
152
- ### vs. `pd.merge`
153
- Exact string match only. Fails on any variation in naming.
154
-
155
- ### vs. fuzzy string matching (`fuzzywuzzy`, `rapidfuzz`)
156
- Character similarity, not semantic meaning. `"iPhone 14 Pro"` vs `"iPhone 14 Pro Max"` scores high — but they are different products. `"CABLE-USBC-200CM-BLK"` vs `"USB-C charging cable 2m black"` scores near zero — even though they are the same item.
157
-
158
- ### vs. embedding similarity alone
159
- Fast and cheap, but no reasoning. Can't explain *why* two values match or catch false positives confidently.
160
-
161
- ### llm-join
162
- Embeddings narrow down candidates (fast, cheap). LLM makes the final call with context (accurate). You get the best of both.
163
-
164
- ---
165
-
166
- ## Real-World Use Cases
167
-
168
- | Domain | Left table | Right table | Problem |
169
- |--------|-----------|-------------|---------|
170
- | **Supply chain** | Buyer catalog SKU | Supplier SKU | Match products across 50+ vendor catalogs |
171
- | **Finance** | Expense report payee | GL account / vendor master | Reconcile transactions automatically |
172
- | **Legal / M&A** | Contract party name | Corporate registry | Identify true legal entity |
173
- | **Compliance** | Customer name | OFAC sanctions list | Sanctions screening at scale |
174
- | **Retail / e-commerce** | Marketplace product listing | Master product catalog | Deduplicate listings across 50+ sellers |
175
- | **Logistics** | Shipment description | Harmonized tariff code | Auto-classify goods at customs |
176
- | **E-commerce** | Marketplace listing | Master product catalog | Deduplicate across platforms |
177
- | **Research** | Author name | Citation database | Disambiguate authors |
178
- | **Government** | Vendor name | Tax registry | Consolidate procurement spend |
179
- | **Real estate** | Raw address input | Property records DB | Standardize and match addresses |
180
-
181
- ---
182
-
183
177
  ## How It Works
184
178
 
185
179
  **Example:** A retailer's internal purchase orders use plain English product names. Their supplier sends a catalog with SKU codes. `pd.merge` matches nothing. llm-join bridges the gap.
@@ -332,7 +326,7 @@ Convert every value to a vector. Use faiss to find the top-K most similar candid
332
326
 
333
327
  ### Stage 2: LLM scores only the hard cases (accurate)
334
328
 
335
- Your LLM sees a small batch of plausible candidates per row — not the full cross product. It scores each candidate; the highest score above `threshold` wins. One best match per left row.
329
+ Your LLM sees a small batch of plausible candidates per row — not the full cross product. It scores each candidate; the highest score above `threshold` wins.
336
330
 
337
331
  Query: `"USB-C charging cable 2m black"`
338
332
 
@@ -360,6 +354,7 @@ result = fuzzy_join(
360
354
  left_on="vendor", right_on="supplier",
361
355
  llm=my_llm,
362
356
  embed_fn=my_embed,
357
+ context="...",
363
358
  top_k=3, # fewer candidates = fewer LLM tokens per row
364
359
  embed_threshold=0.95, # skip LLM entirely if embedding match score > 0.95
365
360
  max_llm_calls=1000, # hard cap — warns and returns partial result if hit
@@ -415,6 +410,7 @@ result = fuzzy_join(
415
410
  right_on="supplier_name",
416
411
  llm=my_llm,
417
412
  embed_fn=my_embed,
413
+ context="company names — match legal entity variants",
418
414
  return_reasoning=True,
419
415
  )
420
416
 
@@ -434,6 +430,7 @@ result = fuzzy_join(
434
430
  right_on="supplier_name",
435
431
  llm=my_llm,
436
432
  embed_fn=my_embed,
433
+ context="company names — match legal entity variants",
437
434
  embed_threshold=0.95, # skip LLM if embedding match is obvious
438
435
  max_llm_calls=500, # hard cap — warns and returns partial result if hit
439
436
  top_k=3, # fewer candidates = fewer LLM tokens
@@ -442,26 +439,31 @@ result = fuzzy_join(
442
439
 
443
440
  ### Left join (audit unmatched rows)
444
441
 
445
- `how="left"` keeps all left rows — unmatched ones get NaN right columns. Useful to see what the LLM failed to match.
442
+ `how="left"` keeps all left rows — unmatched ones get NaN right columns. Useful to see what failed to match.
446
443
 
447
444
  ```python
448
- result = fuzzy_join(df1, df2, left_on="a", right_on="b", llm=my_llm, embed_fn=my_embed, how="left")
445
+ result = fuzzy_join(
446
+ df1, df2,
447
+ left_on="vendor", right_on="supplier_name",
448
+ llm=my_llm, embed_fn=my_embed,
449
+ context="company names — match legal entity variants",
450
+ how="left",
451
+ )
449
452
 
450
- # Rows with no match
451
- unmatched = result[result["b"].isna()]
453
+ unmatched = result[result["supplier_name"].isna()]
452
454
  ```
453
455
 
454
- > **Note:** `how="outer"` is useful for reconciliation — unmatched left rows are left values with no match above threshold; unmatched right rows are right values that were never selected as a best match for any left row. `cross` join is not supported (it would be the naive O(n×m) approach that llm-join is designed to avoid).
456
+ > **Note:** `how="outer"` is useful for reconciliation — unmatched left rows are values with no match above threshold; unmatched right rows are values never selected as a best match. `cross` join is not supported (it would be the naive O(n×m) approach llm-join is designed to avoid).
455
457
 
456
458
  ### Multi-column join key
457
459
 
458
460
  ```python
459
461
  # orders_df has separate "product_name" and "category" columns
460
- # catalog_df has a single "sku_description" column like "CABLE / USB-C / 200CM / BLK"
462
+ # catalog_df has a single "sku_description" column
461
463
 
462
464
  result = fuzzy_join(
463
465
  orders_df, catalog_df,
464
- left_on=["product_name", "category"], # concatenated: "USB-C cable 2m · electronics"
466
+ left_on=["product_name", "category"], # concatenated into one key
465
467
  right_on="sku_description",
466
468
  llm=my_llm,
467
469
  embed_fn=my_embed,
@@ -474,22 +476,16 @@ result = fuzzy_join(
474
476
  Each `fuzzy_join` returns a regular DataFrame — pipe them like `pd.merge`.
475
477
 
476
478
  ```python
477
- # df1: transactions (vendor + product columns)
478
- # df2: vendor master
479
- # df3: product catalog
480
-
481
479
  # Step 1 — match vendors
482
480
  step1 = fuzzy_join(
483
481
  df1, df2,
484
- left_on="vendor",
485
- right_on="supplier_name",
486
- llm=my_llm,
487
- embed_fn=my_embed,
488
- how="left",
489
- return_reasoning=True,
482
+ left_on="vendor", right_on="supplier_name",
483
+ llm=my_llm, embed_fn=my_embed,
484
+ context="company names — match legal entity variants",
485
+ how="left", return_reasoning=True,
490
486
  )
491
487
  step1 = step1.rename(columns={
492
- "_llm_score": "_vendor_score",
488
+ "_llm_score": "_vendor_score",
493
489
  "_llm_reasoning": "_vendor_reasoning",
494
490
  "_match_method": "_vendor_method",
495
491
  })
@@ -497,14 +493,11 @@ step1 = step1.rename(columns={
497
493
  # Step 2 — match products on result of step 1
498
494
  result = fuzzy_join(
499
495
  step1, df3,
500
- left_on="product",
501
- right_on="catalog_item",
502
- llm=my_llm,
503
- embed_fn=my_embed,
504
- how="left",
505
- return_reasoning=True,
496
+ left_on="product", right_on="catalog_item",
497
+ llm=my_llm, embed_fn=my_embed,
498
+ context="product names — match internal descriptions to catalog items",
499
+ how="left", return_reasoning=True,
506
500
  )
507
- # result now has _vendor_score + _llm_score without column collision
508
501
  ```
509
502
 
510
503
  ---
@@ -526,7 +519,7 @@ llm = lambda p: client.chat.completions.create(
526
519
  import anthropic
527
520
  client = anthropic.Anthropic()
528
521
  llm = lambda p: client.messages.create(
529
- model="claude-opus-4-7", max_tokens=512,
522
+ model="claude-opus-4-5", max_tokens=512,
530
523
  messages=[{"role": "user", "content": p}]
531
524
  ).content[0].text
532
525
 
@@ -549,6 +542,8 @@ llm = lambda p: requests.post(
549
542
  ).json()["response"]
550
543
  ```
551
544
 
545
+ ---
546
+
552
547
  ## Works with Any Embedding Function
553
548
 
554
549
  Pass any callable `(list[str]) -> np.ndarray` (shape `[n, dim]`, dtype `float32`).
@@ -556,7 +551,7 @@ Pass any callable `(list[str]) -> np.ndarray` (shape `[n, dim]`, dtype `float32`
556
551
  ```python
557
552
  import numpy as np
558
553
 
559
- # OpenAI embeddings
554
+ # OpenAI
560
555
  import openai
561
556
  client = openai.OpenAI()
562
557
  def my_embed(texts):
@@ -570,7 +565,7 @@ def my_embed(texts):
570
565
  response = co.embed(texts=texts, model="embed-english-v3.0", input_type="search_document")
571
566
  return np.array(response.embeddings, dtype="float32")
572
567
 
573
- # sentence-transformers (local)
568
+ # sentence-transformers (local, no API key)
574
569
  from sentence_transformers import SentenceTransformer
575
570
  model = SentenceTransformer("all-MiniLM-L6-v2")
576
571
  def my_embed(texts):
@@ -588,20 +583,22 @@ def my_embed(texts):
588
583
 
589
584
  ---
590
585
 
591
- ## vs. Alternatives
592
-
593
- | | llm-join | pd.merge | fuzzywuzzy | Jellyjoin | LinkTransformer |
594
- |---|:---:|:---:|:---:|:---:|:---:|
595
- | Semantic matching | | | | | |
596
- | LLM makes final decision | | | | | optional |
597
- | Reasoning per match | | | | | |
598
- | Domain context injection | | | | | ✗ |
599
- | Bring-your-own LLM callable | | n/a | n/a | | |
600
- | Bring-your-own embed callable | | n/a | n/a | | |
601
- | Enterprise-safe (no forced downloads) | | | | partial | ✗ |
602
- | Hard cost cap (`max_llm_calls`) | | n/a | n/a | ✗ | ✗ |
603
- | License | **MIT** | BSD | MIT | MIT | **GPL-3.0** |
604
- | Install size | ~50 MB | ~20 MB | ~30 MB | ~60 MB | **~3 GB** |
586
+ ## Features
587
+
588
+ - **Semantic matching** joins on meaning, not character similarity
589
+ - **Two-stage pipeline** — embeddings eliminate 99%+ of candidates cheaply; LLM scores only the hard cases
590
+ - **Domain context injection** `context` and `column_context` tell the LLM what the columns represent, improving accuracy
591
+ - **Bring-your-own LLM** any callable `(str) -> str`; works with OpenAI, Anthropic, Gemini, Ollama, or any custom endpoint
592
+ - **Bring-your-own embeddings** any callable `(list[str]) -> np.ndarray`; works with any embedding model or API
593
+ - **Full join semantics** `inner`, `left`, `right`, `outer` same API as `pd.merge`
594
+ - **Multi-column join keys** pass a list to `left_on` / `right_on`; values are concatenated automatically
595
+ - **Tie handling** when multiple candidates score equally, all are returned (correct join semantics)
596
+ - **Reasoning output** `return_reasoning=True` adds `_llm_score`, `_llm_reasoning`, `_embed_rank`, `_match_method` columns
597
+ - **Embed threshold shortcut** `embed_threshold` skips LLM entirely for obvious matches, saving cost
598
+ - **Embed fallback** if LLM fails all retries, falls back to top embed candidate automatically (`_match_method="embed_fallback"`)
599
+ - **Cost controls** `top_k`, `embed_threshold`, `max_llm_calls` give full budget control
600
+ - **Retry with backoff** — `max_retries` retries failed LLM calls with exponential backoff (1s, 2s, 4s…)
601
+ - **MIT license** — use in commercial projects without restriction
605
602
 
606
603
  ---
607
604
 
@@ -609,20 +606,20 @@ def my_embed(texts):
609
606
 
610
607
  | Parameter | Default | Description |
611
608
  |-----------|---------|-------------|
612
- | `left_on` | required | Column name(s) in df1 |
613
- | `right_on` | required | Column name(s) in df2 |
609
+ | `left_on` | required | Column name(s) in df1. Pass a list for multi-column keys. |
610
+ | `right_on` | required | Column name(s) in df2. Pass a list for multi-column keys. |
614
611
  | `llm` | required | Callable `(prompt: str) -> str` — your LLM function |
615
612
  | `embed_fn` | required | Callable `(list[str]) -> np.ndarray` — your embedding function |
616
613
  | `context` | required | Domain context injected into LLM prompt — describe what the columns represent and what kind of match to make |
617
- | `column_context` | `{}` | Per-column context dict `{"col": "description"}` |
618
- | `top_k` | `5` | Embedding candidates retrieved per row before LLM scoring |
619
- | `batch_size` | `32` | Reserved for future LLM batching (passed through to config) |
620
- | `threshold` | `0.7` | Minimum LLM score (0–1) to accept a match |
621
- | `how` | `"inner"` | Join type: `inner` (matched pairs only) / `left` (all left rows, NaN where unmatched) / `right` (all right rows, NaN where no left row matched them) / `outer` (full picture both unmatched left and unmatched right rows). |
622
- | `embed_threshold` | `None` | Skip LLM when embedding score is decisive (saves cost) |
623
- | `max_llm_calls` | `None` | Hard cap on LLM calls returns partial result with warning if hit |
624
- | `max_retries` | `3` | Retry failed LLM calls with exponential backoff (1s, 2s, 4s…). Set `0` to disable. |
625
- | `return_reasoning` | `False` | Append `_llm_score`, `_llm_reasoning`, `_embed_rank`, `_match_method` columns. `_match_method` is `"llm"` or `"embed_threshold"` — tells you which rows skipped the LLM. |
614
+ | `column_context` | `{}` | Per-column context dict `{"col": "description"}` — adds column-level detail to the prompt |
615
+ | `top_k` | `5` | Number of embedding candidates retrieved per left row before LLM scoring |
616
+ | `threshold` | `0.7` | Minimum LLM score (0–1) to accept a match. Scores below this are rejected. |
617
+ | `how` | `"inner"` | Join type: `inner` (matched pairs only) / `left` (all left rows) / `right` (all right rows) / `outer` (all rows both sides) |
618
+ | `embed_threshold` | `None` | If set, skip LLM when top embed score this valuematch accepted as-is |
619
+ | `max_llm_calls` | `None` | Hard cap on total LLM calls. Emits a warning and returns partial result if hit. |
620
+ | `max_retries` | `3` | Retry failed LLM calls with exponential backoff (1s, 2s, 4s…). Set `0` to disable. On full failure, falls back to top embed candidate. |
621
+ | `batch_size` | `32` | Embedding batch size for `embed_fn` calls |
622
+ | `return_reasoning` | `False` | Append debug columns: `_llm_score`, `_llm_reasoning`, `_embed_rank`, `_match_method` (`"llm"` / `"embed_threshold"` / `"embed_fallback"`) |
626
623
 
627
624
  ---
628
625
 
@@ -4,15 +4,21 @@
4
4
 
5
5
  `pd.merge` joins on exact values. `llm-join` joins on *meaning* — using embeddings to find candidates and an LLM you already have to decide if they match.
6
6
 
7
+ ```python
8
+ from llm_join import fuzzy_join
9
+
10
+ result = fuzzy_join(df1, df2, left_on="vendor", right_on="supplier_name", llm=my_llm, embed_fn=my_embed, context="...")
11
+ ```
12
+
7
13
  ---
8
14
 
9
15
  ## Table of Contents
10
16
 
11
17
  - [The Problem](#the-problem)
12
- - [Install](#install)
13
- - [Quick Start](#quick-start)
14
18
  - [Why llm-join](#why-llm-join)
15
19
  - [Real-World Use Cases](#real-world-use-cases)
20
+ - [Install](#install)
21
+ - [Quick Start](#quick-start)
16
22
  - [How It Works](#how-it-works)
17
23
  - [Cost & Scale](#cost--scale)
18
24
  - [The problem with naive LLM joins](#the-problem-with-naive-llm-joins)
@@ -30,16 +36,10 @@
30
36
  - [Chaining multiple joins](#chaining-multiple-joins)
31
37
  - [Works with Any LLM](#works-with-any-llm)
32
38
  - [Works with Any Embedding Function](#works-with-any-embedding-function)
33
- - [vs. Alternatives](#vs-alternatives)
39
+ - [Features](#features)
34
40
  - [Parameters](#parameters)
35
41
  - [License](#license)
36
42
 
37
- ```python
38
- from llm_join import fuzzy_join
39
-
40
- result = fuzzy_join(df1, df2, left_on="vendor", right_on="supplier_name", llm=my_llm, embed_fn=my_embed)
41
- ```
42
-
43
43
  ---
44
44
 
45
45
  ## The Problem
@@ -59,19 +59,52 @@ You have two DataFrames. Same data, different text:
59
59
 
60
60
  ---
61
61
 
62
+ ## Why llm-join
63
+
64
+ ### vs. `pd.merge`
65
+ Exact string match only. Fails on any variation in naming.
66
+
67
+ ### vs. fuzzy string matching (`fuzzywuzzy`, `rapidfuzz`)
68
+ Character similarity, not semantic meaning. `"iPhone 14 Pro"` vs `"iPhone 14 Pro Max"` scores high — but they are different products. `"CABLE-USBC-200CM-BLK"` vs `"USB-C charging cable 2m black"` scores near zero — even though they are the same item.
69
+
70
+ ### vs. embedding similarity alone
71
+ Fast and cheap, but no reasoning. Can't explain *why* two values match or catch false positives confidently.
72
+
73
+ ### llm-join
74
+ Embeddings narrow down candidates (fast, cheap). LLM makes the final call with context (accurate). You get the best of both.
75
+
76
+ ---
77
+
78
+ ## Real-World Use Cases
79
+
80
+ | Domain | Left table | Right table | Problem |
81
+ |--------|-----------|-------------|---------|
82
+ | **Supply chain** | Buyer product description | Supplier SKU | Match products across 50+ vendor catalogs |
83
+ | **Finance** | Expense report payee | GL account / vendor master | Reconcile transactions automatically |
84
+ | **Legal / M&A** | Contract party name | Corporate registry | Identify true legal entity |
85
+ | **Compliance** | Customer name | OFAC sanctions list | Sanctions screening at scale |
86
+ | **Retail / e-commerce** | Marketplace product listing | Master product catalog | Deduplicate listings across 50+ sellers |
87
+ | **Logistics** | Shipment description | Harmonized tariff code | Auto-classify goods at customs |
88
+ | **Research** | Author name | Citation database | Disambiguate authors |
89
+ | **Government** | Vendor name | Tax registry | Consolidate procurement spend |
90
+ | **Real estate** | Raw address input | Property records DB | Standardize and match addresses |
91
+
92
+ ---
93
+
62
94
  ## Install
63
95
 
64
96
  ```bash
65
- # From GitHub (not yet on PyPI)
66
- pip install git+https://github.com/adityabalki/llm-join.git
97
+ pip install llm-join
98
+ ```
67
99
 
68
- # Or clone and install locally
100
+ ```bash
101
+ # Or install from source
69
102
  git clone https://github.com/adityabalki/llm-join.git
70
103
  cd llm-join
71
104
  pip install -e .
72
105
 
73
- # Or copy the wheel to air-gapped machines
74
- pip install llm_join-0.1.0-py3-none-any.whl
106
+ # Or install from wheel (air-gapped machines)
107
+ pip install llm_join-0.2.0-py3-none-any.whl
75
108
  ```
76
109
 
77
110
  ---
@@ -80,10 +113,10 @@ pip install llm_join-0.1.0-py3-none-any.whl
80
113
 
81
114
  ```python
82
115
  import pandas as pd
116
+ import numpy as np
83
117
  import openai
84
118
  from llm_join import fuzzy_join
85
119
 
86
- # Your data
87
120
  df1 = pd.DataFrame({
88
121
  "vendor": ["Goldman Sachs & Co.", "Amazon Web Services", "Microsoft Corp"],
89
122
  "spend": [1_200_000, 890_000, 340_000]
@@ -94,26 +127,23 @@ df2 = pd.DataFrame({
94
127
  "category": ["Finance", "Cloud", "Software"]
95
128
  })
96
129
 
97
- # Wire up any LLM you already use
98
130
  client = openai.OpenAI()
99
- def llm(prompt):
131
+
132
+ def my_llm(prompt):
100
133
  return client.chat.completions.create(
101
134
  model="gpt-4o-mini",
102
135
  messages=[{"role": "user", "content": prompt}]
103
136
  ).choices[0].message.content
104
137
 
105
- # Wire up your embedding function
106
- import numpy as np
107
138
  def my_embed(texts):
108
139
  response = client.embeddings.create(model="text-embedding-3-small", input=texts)
109
140
  return np.array([d.embedding for d in response.data], dtype="float32")
110
141
 
111
- # Join (inner by default — only rows that matched)
112
142
  result = fuzzy_join(
113
143
  df1, df2,
114
144
  left_on="vendor",
115
145
  right_on="supplier_name",
116
- llm=llm,
146
+ llm=my_llm,
117
147
  embed_fn=my_embed,
118
148
  context="company names — match legal entity variants and abbreviations",
119
149
  how="inner", # "inner" | "left" | "right" | "outer"
@@ -130,39 +160,6 @@ print(result)
130
160
 
131
161
  ---
132
162
 
133
- ## Why llm-join
134
-
135
- ### vs. `pd.merge`
136
- Exact string match only. Fails on any variation in naming.
137
-
138
- ### vs. fuzzy string matching (`fuzzywuzzy`, `rapidfuzz`)
139
- Character similarity, not semantic meaning. `"iPhone 14 Pro"` vs `"iPhone 14 Pro Max"` scores high — but they are different products. `"CABLE-USBC-200CM-BLK"` vs `"USB-C charging cable 2m black"` scores near zero — even though they are the same item.
140
-
141
- ### vs. embedding similarity alone
142
- Fast and cheap, but no reasoning. Can't explain *why* two values match or catch false positives confidently.
143
-
144
- ### llm-join
145
- Embeddings narrow down candidates (fast, cheap). LLM makes the final call with context (accurate). You get the best of both.
146
-
147
- ---
148
-
149
- ## Real-World Use Cases
150
-
151
- | Domain | Left table | Right table | Problem |
152
- |--------|-----------|-------------|---------|
153
- | **Supply chain** | Buyer catalog SKU | Supplier SKU | Match products across 50+ vendor catalogs |
154
- | **Finance** | Expense report payee | GL account / vendor master | Reconcile transactions automatically |
155
- | **Legal / M&A** | Contract party name | Corporate registry | Identify true legal entity |
156
- | **Compliance** | Customer name | OFAC sanctions list | Sanctions screening at scale |
157
- | **Retail / e-commerce** | Marketplace product listing | Master product catalog | Deduplicate listings across 50+ sellers |
158
- | **Logistics** | Shipment description | Harmonized tariff code | Auto-classify goods at customs |
159
- | **E-commerce** | Marketplace listing | Master product catalog | Deduplicate across platforms |
160
- | **Research** | Author name | Citation database | Disambiguate authors |
161
- | **Government** | Vendor name | Tax registry | Consolidate procurement spend |
162
- | **Real estate** | Raw address input | Property records DB | Standardize and match addresses |
163
-
164
- ---
165
-
166
163
  ## How It Works
167
164
 
168
165
  **Example:** A retailer's internal purchase orders use plain English product names. Their supplier sends a catalog with SKU codes. `pd.merge` matches nothing. llm-join bridges the gap.
@@ -315,7 +312,7 @@ Convert every value to a vector. Use faiss to find the top-K most similar candid
315
312
 
316
313
  ### Stage 2: LLM scores only the hard cases (accurate)
317
314
 
318
- Your LLM sees a small batch of plausible candidates per row — not the full cross product. It scores each candidate; the highest score above `threshold` wins. One best match per left row.
315
+ Your LLM sees a small batch of plausible candidates per row — not the full cross product. It scores each candidate; the highest score above `threshold` wins.
319
316
 
320
317
  Query: `"USB-C charging cable 2m black"`
321
318
 
@@ -343,6 +340,7 @@ result = fuzzy_join(
343
340
  left_on="vendor", right_on="supplier",
344
341
  llm=my_llm,
345
342
  embed_fn=my_embed,
343
+ context="...",
346
344
  top_k=3, # fewer candidates = fewer LLM tokens per row
347
345
  embed_threshold=0.95, # skip LLM entirely if embedding match score > 0.95
348
346
  max_llm_calls=1000, # hard cap — warns and returns partial result if hit
@@ -398,6 +396,7 @@ result = fuzzy_join(
398
396
  right_on="supplier_name",
399
397
  llm=my_llm,
400
398
  embed_fn=my_embed,
399
+ context="company names — match legal entity variants",
401
400
  return_reasoning=True,
402
401
  )
403
402
 
@@ -417,6 +416,7 @@ result = fuzzy_join(
417
416
  right_on="supplier_name",
418
417
  llm=my_llm,
419
418
  embed_fn=my_embed,
419
+ context="company names — match legal entity variants",
420
420
  embed_threshold=0.95, # skip LLM if embedding match is obvious
421
421
  max_llm_calls=500, # hard cap — warns and returns partial result if hit
422
422
  top_k=3, # fewer candidates = fewer LLM tokens
@@ -425,26 +425,31 @@ result = fuzzy_join(
425
425
 
426
426
  ### Left join (audit unmatched rows)
427
427
 
428
- `how="left"` keeps all left rows — unmatched ones get NaN right columns. Useful to see what the LLM failed to match.
428
+ `how="left"` keeps all left rows — unmatched ones get NaN right columns. Useful to see what failed to match.
429
429
 
430
430
  ```python
431
- result = fuzzy_join(df1, df2, left_on="a", right_on="b", llm=my_llm, embed_fn=my_embed, how="left")
431
+ result = fuzzy_join(
432
+ df1, df2,
433
+ left_on="vendor", right_on="supplier_name",
434
+ llm=my_llm, embed_fn=my_embed,
435
+ context="company names — match legal entity variants",
436
+ how="left",
437
+ )
432
438
 
433
- # Rows with no match
434
- unmatched = result[result["b"].isna()]
439
+ unmatched = result[result["supplier_name"].isna()]
435
440
  ```
436
441
 
437
- > **Note:** `how="outer"` is useful for reconciliation — unmatched left rows are left values with no match above threshold; unmatched right rows are right values that were never selected as a best match for any left row. `cross` join is not supported (it would be the naive O(n×m) approach that llm-join is designed to avoid).
442
+ > **Note:** `how="outer"` is useful for reconciliation — unmatched left rows are values with no match above threshold; unmatched right rows are values never selected as a best match. `cross` join is not supported (it would be the naive O(n×m) approach llm-join is designed to avoid).
438
443
 
439
444
  ### Multi-column join key
440
445
 
441
446
  ```python
442
447
  # orders_df has separate "product_name" and "category" columns
443
- # catalog_df has a single "sku_description" column like "CABLE / USB-C / 200CM / BLK"
448
+ # catalog_df has a single "sku_description" column
444
449
 
445
450
  result = fuzzy_join(
446
451
  orders_df, catalog_df,
447
- left_on=["product_name", "category"], # concatenated: "USB-C cable 2m · electronics"
452
+ left_on=["product_name", "category"], # concatenated into one key
448
453
  right_on="sku_description",
449
454
  llm=my_llm,
450
455
  embed_fn=my_embed,
@@ -457,22 +462,16 @@ result = fuzzy_join(
457
462
  Each `fuzzy_join` returns a regular DataFrame — pipe them like `pd.merge`.
458
463
 
459
464
  ```python
460
- # df1: transactions (vendor + product columns)
461
- # df2: vendor master
462
- # df3: product catalog
463
-
464
465
  # Step 1 — match vendors
465
466
  step1 = fuzzy_join(
466
467
  df1, df2,
467
- left_on="vendor",
468
- right_on="supplier_name",
469
- llm=my_llm,
470
- embed_fn=my_embed,
471
- how="left",
472
- return_reasoning=True,
468
+ left_on="vendor", right_on="supplier_name",
469
+ llm=my_llm, embed_fn=my_embed,
470
+ context="company names — match legal entity variants",
471
+ how="left", return_reasoning=True,
473
472
  )
474
473
  step1 = step1.rename(columns={
475
- "_llm_score": "_vendor_score",
474
+ "_llm_score": "_vendor_score",
476
475
  "_llm_reasoning": "_vendor_reasoning",
477
476
  "_match_method": "_vendor_method",
478
477
  })
@@ -480,14 +479,11 @@ step1 = step1.rename(columns={
480
479
  # Step 2 — match products on result of step 1
481
480
  result = fuzzy_join(
482
481
  step1, df3,
483
- left_on="product",
484
- right_on="catalog_item",
485
- llm=my_llm,
486
- embed_fn=my_embed,
487
- how="left",
488
- return_reasoning=True,
482
+ left_on="product", right_on="catalog_item",
483
+ llm=my_llm, embed_fn=my_embed,
484
+ context="product names — match internal descriptions to catalog items",
485
+ how="left", return_reasoning=True,
489
486
  )
490
- # result now has _vendor_score + _llm_score without column collision
491
487
  ```
492
488
 
493
489
  ---
@@ -509,7 +505,7 @@ llm = lambda p: client.chat.completions.create(
509
505
  import anthropic
510
506
  client = anthropic.Anthropic()
511
507
  llm = lambda p: client.messages.create(
512
- model="claude-opus-4-7", max_tokens=512,
508
+ model="claude-opus-4-5", max_tokens=512,
513
509
  messages=[{"role": "user", "content": p}]
514
510
  ).content[0].text
515
511
 
@@ -532,6 +528,8 @@ llm = lambda p: requests.post(
532
528
  ).json()["response"]
533
529
  ```
534
530
 
531
+ ---
532
+
535
533
  ## Works with Any Embedding Function
536
534
 
537
535
  Pass any callable `(list[str]) -> np.ndarray` (shape `[n, dim]`, dtype `float32`).
@@ -539,7 +537,7 @@ Pass any callable `(list[str]) -> np.ndarray` (shape `[n, dim]`, dtype `float32`
539
537
  ```python
540
538
  import numpy as np
541
539
 
542
- # OpenAI embeddings
540
+ # OpenAI
543
541
  import openai
544
542
  client = openai.OpenAI()
545
543
  def my_embed(texts):
@@ -553,7 +551,7 @@ def my_embed(texts):
553
551
  response = co.embed(texts=texts, model="embed-english-v3.0", input_type="search_document")
554
552
  return np.array(response.embeddings, dtype="float32")
555
553
 
556
- # sentence-transformers (local)
554
+ # sentence-transformers (local, no API key)
557
555
  from sentence_transformers import SentenceTransformer
558
556
  model = SentenceTransformer("all-MiniLM-L6-v2")
559
557
  def my_embed(texts):
@@ -571,20 +569,22 @@ def my_embed(texts):
571
569
 
572
570
  ---
573
571
 
574
- ## vs. Alternatives
575
-
576
- | | llm-join | pd.merge | fuzzywuzzy | Jellyjoin | LinkTransformer |
577
- |---|:---:|:---:|:---:|:---:|:---:|
578
- | Semantic matching | | | | | |
579
- | LLM makes final decision | | | | | optional |
580
- | Reasoning per match | | | | | |
581
- | Domain context injection | | | | | ✗ |
582
- | Bring-your-own LLM callable | | n/a | n/a | | |
583
- | Bring-your-own embed callable | | n/a | n/a | | |
584
- | Enterprise-safe (no forced downloads) | | | | partial | ✗ |
585
- | Hard cost cap (`max_llm_calls`) | | n/a | n/a | ✗ | ✗ |
586
- | License | **MIT** | BSD | MIT | MIT | **GPL-3.0** |
587
- | Install size | ~50 MB | ~20 MB | ~30 MB | ~60 MB | **~3 GB** |
572
+ ## Features
573
+
574
+ - **Semantic matching** joins on meaning, not character similarity
575
+ - **Two-stage pipeline** — embeddings eliminate 99%+ of candidates cheaply; LLM scores only the hard cases
576
+ - **Domain context injection** `context` and `column_context` tell the LLM what the columns represent, improving accuracy
577
+ - **Bring-your-own LLM** any callable `(str) -> str`; works with OpenAI, Anthropic, Gemini, Ollama, or any custom endpoint
578
+ - **Bring-your-own embeddings** any callable `(list[str]) -> np.ndarray`; works with any embedding model or API
579
+ - **Full join semantics** `inner`, `left`, `right`, `outer` same API as `pd.merge`
580
+ - **Multi-column join keys** pass a list to `left_on` / `right_on`; values are concatenated automatically
581
+ - **Tie handling** when multiple candidates score equally, all are returned (correct join semantics)
582
+ - **Reasoning output** `return_reasoning=True` adds `_llm_score`, `_llm_reasoning`, `_embed_rank`, `_match_method` columns
583
+ - **Embed threshold shortcut** `embed_threshold` skips LLM entirely for obvious matches, saving cost
584
+ - **Embed fallback** if LLM fails all retries, falls back to top embed candidate automatically (`_match_method="embed_fallback"`)
585
+ - **Cost controls** `top_k`, `embed_threshold`, `max_llm_calls` give full budget control
586
+ - **Retry with backoff** — `max_retries` retries failed LLM calls with exponential backoff (1s, 2s, 4s…)
587
+ - **MIT license** — use in commercial projects without restriction
588
588
 
589
589
  ---
590
590
 
@@ -592,20 +592,20 @@ def my_embed(texts):
592
592
 
593
593
  | Parameter | Default | Description |
594
594
  |-----------|---------|-------------|
595
- | `left_on` | required | Column name(s) in df1 |
596
- | `right_on` | required | Column name(s) in df2 |
595
+ | `left_on` | required | Column name(s) in df1. Pass a list for multi-column keys. |
596
+ | `right_on` | required | Column name(s) in df2. Pass a list for multi-column keys. |
597
597
  | `llm` | required | Callable `(prompt: str) -> str` — your LLM function |
598
598
  | `embed_fn` | required | Callable `(list[str]) -> np.ndarray` — your embedding function |
599
599
  | `context` | required | Domain context injected into LLM prompt — describe what the columns represent and what kind of match to make |
600
- | `column_context` | `{}` | Per-column context dict `{"col": "description"}` |
601
- | `top_k` | `5` | Embedding candidates retrieved per row before LLM scoring |
602
- | `batch_size` | `32` | Reserved for future LLM batching (passed through to config) |
603
- | `threshold` | `0.7` | Minimum LLM score (0–1) to accept a match |
604
- | `how` | `"inner"` | Join type: `inner` (matched pairs only) / `left` (all left rows, NaN where unmatched) / `right` (all right rows, NaN where no left row matched them) / `outer` (full picture both unmatched left and unmatched right rows). |
605
- | `embed_threshold` | `None` | Skip LLM when embedding score is decisive (saves cost) |
606
- | `max_llm_calls` | `None` | Hard cap on LLM calls returns partial result with warning if hit |
607
- | `max_retries` | `3` | Retry failed LLM calls with exponential backoff (1s, 2s, 4s…). Set `0` to disable. |
608
- | `return_reasoning` | `False` | Append `_llm_score`, `_llm_reasoning`, `_embed_rank`, `_match_method` columns. `_match_method` is `"llm"` or `"embed_threshold"` — tells you which rows skipped the LLM. |
600
+ | `column_context` | `{}` | Per-column context dict `{"col": "description"}` — adds column-level detail to the prompt |
601
+ | `top_k` | `5` | Number of embedding candidates retrieved per left row before LLM scoring |
602
+ | `threshold` | `0.7` | Minimum LLM score (0–1) to accept a match. Scores below this are rejected. |
603
+ | `how` | `"inner"` | Join type: `inner` (matched pairs only) / `left` (all left rows) / `right` (all right rows) / `outer` (all rows both sides) |
604
+ | `embed_threshold` | `None` | If set, skip LLM when top embed score this valuematch accepted as-is |
605
+ | `max_llm_calls` | `None` | Hard cap on total LLM calls. Emits a warning and returns partial result if hit. |
606
+ | `max_retries` | `3` | Retry failed LLM calls with exponential backoff (1s, 2s, 4s…). Set `0` to disable. On full failure, falls back to top embed candidate. |
607
+ | `batch_size` | `32` | Embedding batch size for `embed_fn` calls |
608
+ | `return_reasoning` | `False` | Append debug columns: `_llm_score`, `_llm_reasoning`, `_embed_rank`, `_match_method` (`"llm"` / `"embed_threshold"` / `"embed_fallback"`) |
609
609
 
610
610
  ---
611
611
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "llm-join"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Fuzzy join DataFrames using LLM scoring and embedding retrieval"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -12,12 +12,11 @@ license = { text = "MIT" }
12
12
  keywords = ["fuzzy-join", "llm", "entity-resolution", "pandas", "nlp"]
13
13
  dependencies = [
14
14
  "pandas>=1.5",
15
- "numpy>=1.23",
15
+ "numpy>=1.23,<2",
16
16
  "faiss-cpu>=1.7",
17
17
  ]
18
18
 
19
19
  [project.optional-dependencies]
20
- dev = ["pytest>=7", "pytest-asyncio>=0.21"]
21
20
  sentence-transformers = ["sentence-transformers>=2.2"]
22
21
 
23
22
  [tool.hatch.build.targets.wheel]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes