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:
copilot-swe-agent[bot]
2026-03-02 13:27:34 +00:00
parent c724b8d83a
commit 8e955f3c81
7 changed files with 134 additions and 16 deletions
+7 -1
View File
@@ -346,4 +346,10 @@ ENABLE_DEDUPLICATION=True
SHOW_DEDUPLICATION_STEP=True
# Minimum cosine similarity score (01) for two documents to be flagged as
# 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
+9
View File
@@ -330,6 +330,15 @@ class Settings(BaseSettings):
"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
enable_text_quality_check: bool = Field(
+14 -4
View File
@@ -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.
Args:
text: The input text to embed. Truncated to ~8000 tokens worth of
characters to stay within model limits.
text: The input text to embed. Truncated to stay within the
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
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:
model = settings.embedding_model
# Truncate very long texts to stay within token limits (~4 chars per token)
max_chars = 30000
# Truncate to stay within the model's context window.
# 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:
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]
client = _get_embedding_client()
+2
View File
@@ -218,6 +218,7 @@ def get_file_overall_status(db: Session, file_id: int) -> Dict:
"embed_metadata_into_pdf",
"finalize_document_storage",
"send_to_all_destinations",
"compute_embedding",
}
# 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",
"finalize_document_storage",
"send_to_all_destinations",
"compute_embedding",
}
# Add check_for_duplicates if deduplication is enabled
+88 -3
View File
@@ -670,7 +670,7 @@ curl -OJ "http://<your-instance>/api/files/123/download?version=original"
**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**:
- `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**:
- `404`: File not found
- `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.01.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
+7 -2
View File
@@ -868,10 +868,15 @@ After OCR processes a document, its extracted text is converted to a vector embe
| Variable | Description | Default |
|---|---|---|
| `NEAR_DUPLICATE_THRESHOLD` | Minimum cosine similarity (01) 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:
- Is performed **on demand** via `GET /api/files/{id}/duplicates` — not automatically during ingest (OCR text is required).
- Is exposed in the **Duplicates** management page (`/duplicates` → "Near-Duplicate Finder" tab).
- Embeddings are computed **automatically during document ingestion** as a processing step ("Compute Embedding").
- 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.
A score of **≥ 0.90** reliably identifies the same document scanned twice. A score of **0.700.90** suggests partial content overlap. Adjust `NEAR_DUPLICATE_THRESHOLD` to tune sensitivity.
+7 -6
View File
@@ -7,6 +7,7 @@ Covers:
- ``GET /duplicates`` — duplicate management UI page
"""
import json
from unittest.mock import patch
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."""
record = FileRecord(
filehash=filehash,
@@ -30,6 +31,7 @@ def _make_file(db, *, filehash, filename, is_duplicate=False, duplicate_of_id=No
is_duplicate=is_duplicate,
duplicate_of_id=duplicate_of_id,
ocr_text=ocr_text,
embedding=embedding,
)
db.add(record)
db.commit()
@@ -195,25 +197,24 @@ class TestGetFileDuplicates:
assert data["duplicate_of"]["id"] == orig.id
@pytest.mark.integration
@patch("app.utils.similarity.generate_embedding")
def test_near_duplicates_returned(self, mock_embed, client: TestClient, db_session):
def test_near_duplicates_returned(self, client: TestClient, db_session):
"""Near-duplicates found via embedding similarity should appear in results."""
embedding = json.dumps([1.0, 0.0, 0.0])
target = _make_file(
db_session,
filehash="th1",
filename="target.pdf",
ocr_text="Invoice from Acme Corp for January services rendered",
embedding=embedding,
)
similar = _make_file(
db_session,
filehash="th2", # different hash — same content (re-scan)
filename="rescan.pdf",
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")
assert response.status_code == 200
data = response.json()