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.
- {llm_join-0.2.0 → llm_join-0.2.2}/.github/workflows/ci.yml +5 -1
- {llm_join-0.2.0 → llm_join-0.2.2}/PKG-INFO +110 -113
- {llm_join-0.2.0 → llm_join-0.2.2}/README.md +108 -108
- {llm_join-0.2.0 → llm_join-0.2.2}/pyproject.toml +2 -3
- {llm_join-0.2.0 → llm_join-0.2.2}/.gitignore +0 -0
- {llm_join-0.2.0 → llm_join-0.2.2}/llm_join/__init__.py +0 -0
- {llm_join-0.2.0 → llm_join-0.2.2}/llm_join/config.py +0 -0
- {llm_join-0.2.0 → llm_join-0.2.2}/llm_join/join.py +0 -0
- {llm_join-0.2.0 → llm_join-0.2.2}/llm_join/merger.py +0 -0
- {llm_join-0.2.0 → llm_join-0.2.2}/llm_join/prompts.py +0 -0
- {llm_join-0.2.0 → llm_join-0.2.2}/llm_join/retriever.py +0 -0
- {llm_join-0.2.0 → llm_join-0.2.2}/llm_join/scorer.py +0 -0
- {llm_join-0.2.0 → llm_join-0.2.2}/notebooks/test_llm_join.ipynb +0 -0
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
- [
|
|
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
|
-
|
|
83
|
-
|
|
111
|
+
pip install llm-join
|
|
112
|
+
```
|
|
84
113
|
|
|
85
|
-
|
|
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
|
|
91
|
-
pip install llm_join-0.
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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":
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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-
|
|
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
|
|
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
|
-
##
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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` |
|
|
619
|
-
| `
|
|
620
|
-
| `
|
|
621
|
-
| `
|
|
622
|
-
| `
|
|
623
|
-
| `
|
|
624
|
-
| `
|
|
625
|
-
| `return_reasoning` | `False` | Append `_llm_score`, `_llm_reasoning`, `_embed_rank`, `_match_method`
|
|
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 value — match 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
|
-
- [
|
|
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
|
-
|
|
66
|
-
|
|
97
|
+
pip install llm-join
|
|
98
|
+
```
|
|
67
99
|
|
|
68
|
-
|
|
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
|
|
74
|
-
pip install llm_join-0.
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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":
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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-
|
|
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
|
|
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
|
-
##
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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` |
|
|
602
|
-
| `
|
|
603
|
-
| `
|
|
604
|
-
| `
|
|
605
|
-
| `
|
|
606
|
-
| `
|
|
607
|
-
| `
|
|
608
|
-
| `return_reasoning` | `False` | Append `_llm_score`, `_llm_reasoning`, `_embed_rank`, `_match_method`
|
|
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 value — match 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.
|
|
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
|
|
File without changes
|