fix(similarity): truncate text to fit embedding model context window, fix step tracking
- Add EMBEDDING_MAX_TOKENS config (default 8000) for safe text truncation - Use conservative 3 chars/token estimate (was 4) to prevent ContextWindowExceededError - Add compute_embedding to REAL_MAIN_STEPS in both get_file_overall_status and get_step_summary - Fix test_near_duplicates_returned to use pre-computed embeddings - Update .env.demo and docs with EMBEDDING_MAX_TOKENS setting Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -347,3 +347,9 @@ SHOW_DEDUPLICATION_STEP=True
|
|||||||
# Minimum cosine similarity score (0–1) for two documents to be flagged as
|
# Minimum cosine similarity score (0–1) for two documents to be flagged as
|
||||||
# near-duplicates. 0.85 means 85 % semantic overlap. Lower = more matches.
|
# near-duplicates. 0.85 means 85 % semantic overlap. Lower = more matches.
|
||||||
NEAR_DUPLICATE_THRESHOLD=0.85
|
NEAR_DUPLICATE_THRESHOLD=0.85
|
||||||
|
# Model used to generate text embeddings for document similarity.
|
||||||
|
# Must be supported by your OpenAI-compatible API endpoint.
|
||||||
|
EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
# Maximum tokens to send to the embedding model. Set below the model's
|
||||||
|
# context window (e.g. 8000 for an 8192-token model).
|
||||||
|
EMBEDDING_MAX_TOKENS=8000
|
||||||
|
|||||||
@@ -330,6 +330,15 @@ class Settings(BaseSettings):
|
|||||||
"Embeddings drive the document similarity feature. Default: text-embedding-3-small."
|
"Embeddings drive the document similarity feature. Default: text-embedding-3-small."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
embedding_max_tokens: int = Field(
|
||||||
|
default=8000,
|
||||||
|
description=(
|
||||||
|
"Maximum number of tokens to send to the embedding model. "
|
||||||
|
"Text is truncated to approximately this many tokens (using a "
|
||||||
|
"conservative 3-chars-per-token estimate) before calling the API. "
|
||||||
|
"Set this below the model's context window (e.g. 8000 for an 8192-token model)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Text quality check - AI-based assessment of embedded PDF text
|
# Text quality check - AI-based assessment of embedded PDF text
|
||||||
enable_text_quality_check: bool = Field(
|
enable_text_quality_check: bool = Field(
|
||||||
|
|||||||
+14
-4
@@ -42,8 +42,9 @@ def generate_embedding(text: str, model: str | None = None) -> list[float]:
|
|||||||
"""Generate a text embedding vector using the OpenAI-compatible API.
|
"""Generate a text embedding vector using the OpenAI-compatible API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The input text to embed. Truncated to ~8000 tokens worth of
|
text: The input text to embed. Truncated to stay within the
|
||||||
characters to stay within model limits.
|
model's context window based on ``settings.embedding_max_tokens``
|
||||||
|
(default 8 000 tokens ≈ 24 000 characters).
|
||||||
model: The embedding model to use. When ``None`` (the default), the
|
model: The embedding model to use. When ``None`` (the default), the
|
||||||
value of ``settings.embedding_model`` is used.
|
value of ``settings.embedding_model`` is used.
|
||||||
|
|
||||||
@@ -57,9 +58,18 @@ def generate_embedding(text: str, model: str | None = None) -> list[float]:
|
|||||||
if model is None:
|
if model is None:
|
||||||
model = settings.embedding_model
|
model = settings.embedding_model
|
||||||
|
|
||||||
# Truncate very long texts to stay within token limits (~4 chars per token)
|
# Truncate to stay within the model's context window.
|
||||||
max_chars = 30000
|
# Use a conservative estimate of ~3 characters per token so that the
|
||||||
|
# resulting text fits comfortably within ``embedding_max_tokens``.
|
||||||
|
max_chars = settings.embedding_max_tokens * 3
|
||||||
if len(text) > max_chars:
|
if len(text) > max_chars:
|
||||||
|
logger.debug(
|
||||||
|
"Truncating text from %d to %d chars (~%d tokens) for model %s",
|
||||||
|
len(text),
|
||||||
|
max_chars,
|
||||||
|
settings.embedding_max_tokens,
|
||||||
|
model,
|
||||||
|
)
|
||||||
text = text[:max_chars]
|
text = text[:max_chars]
|
||||||
|
|
||||||
client = _get_embedding_client()
|
client = _get_embedding_client()
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ def get_file_overall_status(db: Session, file_id: int) -> Dict:
|
|||||||
"embed_metadata_into_pdf",
|
"embed_metadata_into_pdf",
|
||||||
"finalize_document_storage",
|
"finalize_document_storage",
|
||||||
"send_to_all_destinations",
|
"send_to_all_destinations",
|
||||||
|
"compute_embedding",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add check_for_duplicates if deduplication is enabled
|
# Add check_for_duplicates if deduplication is enabled
|
||||||
@@ -326,6 +327,7 @@ def get_step_summary(db: Session, file_id: int) -> Dict:
|
|||||||
"embed_metadata_into_pdf",
|
"embed_metadata_into_pdf",
|
||||||
"finalize_document_storage",
|
"finalize_document_storage",
|
||||||
"send_to_all_destinations",
|
"send_to_all_destinations",
|
||||||
|
"compute_embedding",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add check_for_duplicates if deduplication is enabled
|
# Add check_for_duplicates if deduplication is enabled
|
||||||
|
|||||||
+88
-3
@@ -670,7 +670,7 @@ curl -OJ "http://<your-instance>/api/files/123/download?version=original"
|
|||||||
|
|
||||||
**GET** `/api/files/{file_id}/similar`
|
**GET** `/api/files/{file_id}/similar`
|
||||||
|
|
||||||
Find documents similar to the specified file using text embeddings and cosine similarity. Similarity scores range from 0 (completely different) to 1 (identical content). Embeddings are generated from OCR-extracted text and cached for subsequent requests.
|
Find documents similar to the specified file using pre-computed text embeddings and cosine similarity. Similarity scores range from 0 (completely different) to 1 (identical content). Embeddings are computed automatically during document ingestion and cached in the database.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
- `limit` (optional, default: `5`, max: `20`): Maximum number of similar documents to return
|
- `limit` (optional, default: `5`, max: `20`): Maximum number of similar documents to return
|
||||||
@@ -706,9 +706,94 @@ curl "http://<your-instance>/api/files/42/similar?limit=10&threshold=0.5"
|
|||||||
**Error Responses**:
|
**Error Responses**:
|
||||||
- `404`: File not found
|
- `404`: File not found
|
||||||
- `422`: Invalid query parameters (limit or threshold out of range)
|
- `422`: Invalid query parameters (limit or threshold out of range)
|
||||||
- `500`: Embedding generation failed
|
- `500`: Internal error
|
||||||
|
|
||||||
> **Note:** Documents without OCR text are excluded from similarity comparisons. The response includes a `message` field when the target file has no OCR text available.
|
> **Note:** Only pre-computed embeddings are used — no API calls are made during the query. If a file's embedding has not been computed yet, the response includes a `message` field explaining this. Documents without OCR text are excluded from similarity comparisons.
|
||||||
|
|
||||||
|
### Similarity Pairs (Corpus-Wide)
|
||||||
|
|
||||||
|
**GET** `/api/similarity/pairs`
|
||||||
|
|
||||||
|
Scan the entire document corpus for pairs of highly similar documents, ranked by score. Unlike the per-file `/files/{id}/similar` endpoint, this discovers all matching pairs across all files.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `threshold` (optional, default: `0.7`, range: `0.0–1.0`): Minimum similarity score for a pair
|
||||||
|
- `limit` (optional, default: `50`, max: `200`): Maximum pairs per page
|
||||||
|
- `page` (optional, default: `1`): Page number
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pairs": [
|
||||||
|
{
|
||||||
|
"file_a": {
|
||||||
|
"file_id": 1,
|
||||||
|
"original_filename": "invoice_jan.pdf",
|
||||||
|
"document_title": "January Invoice",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"created_at": "2026-01-15T10:30:00+00:00"
|
||||||
|
},
|
||||||
|
"file_b": {
|
||||||
|
"file_id": 5,
|
||||||
|
"original_filename": "invoice_feb.pdf",
|
||||||
|
"document_title": "February Invoice",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"created_at": "2026-02-15T10:30:00+00:00"
|
||||||
|
},
|
||||||
|
"similarity_score": 0.94
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_pairs": 12,
|
||||||
|
"threshold": 0.7,
|
||||||
|
"page": 1,
|
||||||
|
"pages": 1,
|
||||||
|
"per_page": 50,
|
||||||
|
"embedding_coverage": {
|
||||||
|
"total_files": 120,
|
||||||
|
"files_with_embedding": 95
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```bash
|
||||||
|
# Find all document pairs above 90% similarity
|
||||||
|
curl "http://<your-instance>/api/similarity/pairs?threshold=0.9"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Embedding Diagnostics
|
||||||
|
|
||||||
|
**GET** `/api/files/{file_id}/embedding-status`
|
||||||
|
|
||||||
|
Check the embedding status for a specific file: whether OCR text is available, whether an embedding has been computed, and how many dimensions it has.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://<your-instance>/api/files/42/embedding-status"
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST** `/api/files/{file_id}/compute-embedding`
|
||||||
|
|
||||||
|
Manually trigger embedding computation for a single file. Useful for debugging or re-computing after configuration changes. Requires OCR text to be available.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://<your-instance>/api/files/42/compute-embedding"
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET** `/api/diagnostic/embeddings`
|
||||||
|
|
||||||
|
Get an overview of embedding coverage across all files: total files, how many have OCR text, how many have embeddings, and per-file status.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://<your-instance>/api/diagnostic/embeddings"
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST** `/api/diagnostic/compute-all-embeddings`
|
||||||
|
|
||||||
|
Queue embedding computation for all files that have OCR text but no embedding yet. Each file is processed as a separate background task.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://<your-instance>/api/diagnostic/compute-all-embeddings"
|
||||||
|
```
|
||||||
|
|
||||||
### Batch Processing
|
### Batch Processing
|
||||||
|
|
||||||
|
|||||||
@@ -868,10 +868,15 @@ After OCR processes a document, its extracted text is converted to a vector embe
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `NEAR_DUPLICATE_THRESHOLD` | Minimum cosine similarity (0–1) for two documents to be considered near-duplicates. `0.85` means ≥ 85 % semantic overlap. | `0.85` |
|
| `NEAR_DUPLICATE_THRESHOLD` | Minimum cosine similarity (0–1) for two documents to be considered near-duplicates. `0.85` means ≥ 85 % semantic overlap. | `0.85` |
|
||||||
|
| `EMBEDDING_MODEL` | Model name for generating text embeddings via the OpenAI-compatible API. Must be supported by the endpoint configured with `OPENAI_BASE_URL`. | `text-embedding-3-small` |
|
||||||
|
| `EMBEDDING_MAX_TOKENS` | Maximum tokens to send to the embedding model. Text is truncated to approximately this many tokens before calling the API. Set below the model's context window (e.g. 8 000 for an 8 192-token model). | `8000` |
|
||||||
|
|
||||||
Near-duplicate detection:
|
Near-duplicate detection:
|
||||||
- Is performed **on demand** via `GET /api/files/{id}/duplicates` — not automatically during ingest (OCR text is required).
|
- Embeddings are computed **automatically during document ingestion** as a processing step ("Compute Embedding").
|
||||||
- Is exposed in the **Duplicates** management page (`/duplicates` → "Near-Duplicate Finder" tab).
|
- A periodic **backfill task** (every 5 minutes) picks up any files that were processed before the embedding pipeline was enabled.
|
||||||
|
- The **Similarity dashboard** (`/similarity`) shows all pairs of documents above the threshold, ranked by score.
|
||||||
|
- The **Duplicates** management page (`/duplicates` → "Near-Duplicate Finder" tab) allows per-file lookup.
|
||||||
|
- Debug endpoints are available to inspect embedding status and trigger recomputation (see API docs).
|
||||||
- Documents without OCR text cannot be compared and are excluded from results.
|
- Documents without OCR text cannot be compared and are excluded from results.
|
||||||
|
|
||||||
A score of **≥ 0.90** reliably identifies the same document scanned twice. A score of **0.70–0.90** suggests partial content overlap. Adjust `NEAR_DUPLICATE_THRESHOLD` to tune sensitivity.
|
A score of **≥ 0.90** reliably identifies the same document scanned twice. A score of **0.70–0.90** suggests partial content overlap. Adjust `NEAR_DUPLICATE_THRESHOLD` to tune sensitivity.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Covers:
|
|||||||
- ``GET /duplicates`` — duplicate management UI page
|
- ``GET /duplicates`` — duplicate management UI page
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -19,7 +20,7 @@ from app.models import FileRecord
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _make_file(db, *, filehash, filename, is_duplicate=False, duplicate_of_id=None, ocr_text=None):
|
def _make_file(db, *, filehash, filename, is_duplicate=False, duplicate_of_id=None, ocr_text=None, embedding=None):
|
||||||
"""Insert a FileRecord and return it."""
|
"""Insert a FileRecord and return it."""
|
||||||
record = FileRecord(
|
record = FileRecord(
|
||||||
filehash=filehash,
|
filehash=filehash,
|
||||||
@@ -30,6 +31,7 @@ def _make_file(db, *, filehash, filename, is_duplicate=False, duplicate_of_id=No
|
|||||||
is_duplicate=is_duplicate,
|
is_duplicate=is_duplicate,
|
||||||
duplicate_of_id=duplicate_of_id,
|
duplicate_of_id=duplicate_of_id,
|
||||||
ocr_text=ocr_text,
|
ocr_text=ocr_text,
|
||||||
|
embedding=embedding,
|
||||||
)
|
)
|
||||||
db.add(record)
|
db.add(record)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -195,25 +197,24 @@ class TestGetFileDuplicates:
|
|||||||
assert data["duplicate_of"]["id"] == orig.id
|
assert data["duplicate_of"]["id"] == orig.id
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@patch("app.utils.similarity.generate_embedding")
|
def test_near_duplicates_returned(self, client: TestClient, db_session):
|
||||||
def test_near_duplicates_returned(self, mock_embed, client: TestClient, db_session):
|
|
||||||
"""Near-duplicates found via embedding similarity should appear in results."""
|
"""Near-duplicates found via embedding similarity should appear in results."""
|
||||||
|
embedding = json.dumps([1.0, 0.0, 0.0])
|
||||||
target = _make_file(
|
target = _make_file(
|
||||||
db_session,
|
db_session,
|
||||||
filehash="th1",
|
filehash="th1",
|
||||||
filename="target.pdf",
|
filename="target.pdf",
|
||||||
ocr_text="Invoice from Acme Corp for January services rendered",
|
ocr_text="Invoice from Acme Corp for January services rendered",
|
||||||
|
embedding=embedding,
|
||||||
)
|
)
|
||||||
similar = _make_file(
|
similar = _make_file(
|
||||||
db_session,
|
db_session,
|
||||||
filehash="th2", # different hash — same content (re-scan)
|
filehash="th2", # different hash — same content (re-scan)
|
||||||
filename="rescan.pdf",
|
filename="rescan.pdf",
|
||||||
ocr_text="Invoice from Acme Corp for January services rendered",
|
ocr_text="Invoice from Acme Corp for January services rendered",
|
||||||
|
embedding=embedding,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Same embedding → cosine similarity = 1.0
|
|
||||||
mock_embed.return_value = [1.0, 0.0, 0.0]
|
|
||||||
|
|
||||||
response = client.get(f"/api/files/{target.id}/duplicates?near_duplicate_threshold=0.8")
|
response = client.get(f"/api/files/{target.id}/duplicates?near_duplicate_threshold=0.8")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|||||||
Reference in New Issue
Block a user