validops-east-1 commited on
Commit
d0736f4
·
1 Parent(s): 2ac4af1

feat: switch local GLiNER2 extraction to ONNX runtime backend

Browse files

Replace the PyTorch (fastino/gliner2-base-v1) backend with the monolithic
lion-ai/gliner2-base-v1-onnx export running on onnxruntime. Identical output
and accuracy on single-invoice docs, ~1.8-2.3x faster, no torch on the hot
path. New GLiNER2ONNXEngine (gliner_onnx.py) reproduces gliner2 schema
semantics (entities, str/list, choice/classification fields) with one ONNX
forward pass per schema group. PyTorch backend preserved as commented
DEPRECATED code for rollback/fallback. Config, env, Dockerfile and
requirements updated; tokenizers pinned for direct tokenizer loading.

.env.example CHANGED
@@ -48,6 +48,8 @@ DATA_DIR=./data
48
 
49
  # --- Local on-device extraction (/json/feature-extract) ---
50
  # All keys are optional; unset values fall back to the defaults shown.
 
 
51
  # GLINER_MODEL=fastino/gliner2-base-v1
52
  # GLINER_ENABLED=true
53
  # GLINER_DEVICE=cpu
 
48
 
49
  # --- Local on-device extraction (/json/feature-extract) ---
50
  # All keys are optional; unset values fall back to the defaults shown.
51
+ # GLINER_MODEL=lion-ai/gliner2-base-v1-onnx
52
+ # DEPRECATED: PyTorch model (torch backend) -- restore to roll back:
53
  # GLINER_MODEL=fastino/gliner2-base-v1
54
  # GLINER_ENABLED=true
55
  # GLINER_DEVICE=cpu
Dockerfile CHANGED
@@ -95,10 +95,13 @@ RUN chmod +x /app/whatsapp-service/server
95
 
96
  RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='ibm-granite/granite-embedding-small-english-r2', local_dir='/app/models/bge-384')" && chown -R appuser:appuser /app/models
97
 
 
 
 
98
  # Pre-download the local on-device extraction model (used by /json/feature-extract)
99
  # so first boot does not hit Hugging Face. The GLINER_MODEL env below points the
100
  # service at this local copy.
101
- RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='fastino/gliner2-base-v1', local_dir='/app/models/gliner2-base-v1')" && chown -R appuser:appuser /app/models
102
 
103
  RUN mkdir -p /app/data /app/logs && \
104
  chown -R appuser:appuser /app/data /app/logs
@@ -111,7 +114,9 @@ ENV PYTHONPATH=/app
111
  ENV PYTHONUNBUFFERED=1
112
 
113
  # Local copy of the on-device extraction model baked in at build time.
114
- ENV GLINER_MODEL=/app/models/gliner2-base-v1
 
 
115
 
116
  # Path to the embedded WhatsApp service binary (started by start.sh as a
117
  # sibling process). Set to an empty value to disable the WhatsApp gateway.
 
95
 
96
  RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='ibm-granite/granite-embedding-small-english-r2', local_dir='/app/models/bge-384')" && chown -R appuser:appuser /app/models
97
 
98
+ # DEPRECATED: PyTorch (torch) backend -- restore to roll back:
99
+ # RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='fastino/gliner2-base-v1', local_dir='/app/models/gliner2-base-v1')" && chown -R appuser:appuser /app/models
100
+
101
  # Pre-download the local on-device extraction model (used by /json/feature-extract)
102
  # so first boot does not hit Hugging Face. The GLINER_MODEL env below points the
103
  # service at this local copy.
104
+ RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='lion-ai/gliner2-base-v1-onnx', local_dir='/app/models/gliner2-base-v1-onnx')" && chown -R appuser:appuser /app/models
105
 
106
  RUN mkdir -p /app/data /app/logs && \
107
  chown -R appuser:appuser /app/data /app/logs
 
114
  ENV PYTHONUNBUFFERED=1
115
 
116
  # Local copy of the on-device extraction model baked in at build time.
117
+ ENV GLINER_MODEL=/app/models/gliner2-base-v1-onnx
118
+ # DEPRECATED: PyTorch (torch) backend -- restore to roll back:
119
+ # ENV GLINER_MODEL=/app/models/gliner2-base-v1
120
 
121
  # Path to the embedded WhatsApp service binary (started by start.sh as a
122
  # sibling process). Set to an empty value to disable the WhatsApp gateway.
app/config.py CHANGED
@@ -223,7 +223,9 @@ class Settings(BaseSettings):
223
  # on-device (no external AI/LLM API). The model is loaded once and cached at
224
  # startup; disable to skip loading and return 503 from the route. Every key
225
  # is optional -- if not set, the default value below is used.
226
- gliner_model: str = Field(default="fastino/gliner2-base-v1", alias="GLINER_MODEL")
 
 
227
  gliner_enabled: bool = Field(default=True, alias="GLINER_ENABLED")
228
  gliner_device: str = Field(default="cpu", alias="GLINER_DEVICE")
229
  gliner_max_concurrent: int = Field(default=2, alias="GLINER_MAX_CONCURRENT")
 
223
  # on-device (no external AI/LLM API). The model is loaded once and cached at
224
  # startup; disable to skip loading and return 503 from the route. Every key
225
  # is optional -- if not set, the default value below is used.
226
+ # DEPRECATED: PyTorch model (torch backend) -- restore to roll back:
227
+ # gliner_model: str = Field(default="fastino/gliner2-base-v1", alias="GLINER_MODEL")
228
+ gliner_model: str = Field(default="lion-ai/gliner2-base-v1-onnx", alias="GLINER_MODEL")
229
  gliner_enabled: bool = Field(default=True, alias="GLINER_ENABLED")
230
  gliner_device: str = Field(default="cpu", alias="GLINER_DEVICE")
231
  gliner_max_concurrent: int = Field(default=2, alias="GLINER_MAX_CONCURRENT")
app/services/gliner_onnx.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ONNX Runtime-backed GLiNER2 extraction engine (no PyTorch at inference).
2
+
3
+ Runs the monolithic ``lion-ai/gliner2-base-v1-onnx`` export -- the GLiNER2
4
+ encoder + span head fused into a single ``model.onnx`` -- on ONNX Runtime.
5
+ The companion ``tokenizer.json`` is loaded through the ``tokenizers`` library,
6
+ so inference does not depend on torch/transformers.
7
+
8
+ This engine reproduces the GLiNER2 schema semantics on top of the ONNX span
9
+ head for the ``/json/feature-extract`` route:
10
+
11
+ * zero-shot entity extraction (mode='entities')
12
+ * structured-JSON field extraction with ``str``/``list`` dtypes and choice
13
+ ("classification") fields (mode='json')
14
+
15
+ Each schema group (the entity list, or each structure parent) runs as its own
16
+ ONNX forward pass, because the graph expects a single ``[P]`` schema per call.
17
+
18
+ Known behavioural differences vs. the PyTorch model (``fastino/gliner2-base-v1``):
19
+ the export fixes the count dimension to 1, so a structure with multiple
20
+ repeating objects yields only the single best object. For documents that
21
+ contain exactly one object (the common case for this route) the output matches
22
+ the PyTorch model.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import re
29
+ from typing import Any, Dict, List, Optional, Tuple
30
+
31
+ import numpy as np
32
+ import onnxruntime as ort
33
+ from huggingface_hub import snapshot_download
34
+ from tokenizers import Tokenizer
35
+
36
+ from gliner2.inference.schema import Schema
37
+
38
+ from app.core.logger import get_logger
39
+
40
+ logger = get_logger(__name__)
41
+
42
+ MAX_WIDTH = 8
43
+ SEP_TEXT = "[SEP_TEXT]"
44
+ DESC_TOKEN = "[DESCRIPTION]"
45
+ P_TOKEN = "[P]"
46
+ C_TOKEN = "[C]"
47
+ E_TOKEN = "[E]"
48
+ R_TOKEN = "[R]"
49
+ L_TOKEN = "[L]"
50
+ SPECIAL_TOKENS = frozenset((P_TOKEN, C_TOKEN, E_TOKEN, R_TOKEN, L_TOKEN))
51
+
52
+ # Identical to gliner2.processor.WhitespaceTokenSplitter.
53
+ _WORD_RE = re.compile(
54
+ r"""(?:https?://[^\s]+|www\.[^\s]+)
55
+ |[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}
56
+ |@[a-z0-9_]+
57
+ |\w+(?:[-_]\w+)*
58
+ |\S""",
59
+ re.VERBOSE | re.IGNORECASE,
60
+ )
61
+
62
+
63
+ def _parse_field_spec(spec: Any) -> Tuple[str, str, Optional[List[str]], Optional[str]]:
64
+ """Parse a structure field spec into ``(name, dtype, choices, description)``.
65
+
66
+ Mirrors ``gliner2``'s ``GLiNER2._parse_field_spec``. Field specs look like
67
+ ``"name::dtype::choices::description"`` where all parts after ``name`` are
68
+ optional; dict specs with ``name``/``dtype``/``choices``/``description``
69
+ keys are accepted too.
70
+ """
71
+ if isinstance(spec, dict):
72
+ return (
73
+ spec.get("name", ""),
74
+ spec.get("dtype", "list"),
75
+ spec.get("choices"),
76
+ spec.get("description"),
77
+ )
78
+
79
+ parts = spec.split("::")
80
+ name = parts[0]
81
+ dtype: str = "list"
82
+ choices: Optional[List[str]] = None
83
+ desc: Optional[str] = None
84
+ dtype_explicitly_set = False
85
+
86
+ for part in parts[1:]:
87
+ if part in ("str", "list"):
88
+ dtype = part
89
+ dtype_explicitly_set = True
90
+ elif part.startswith("[") and part.endswith("]"):
91
+ choices = [c.strip() for c in part[1:-1].split("|")]
92
+ if not dtype_explicitly_set:
93
+ dtype = "str"
94
+ else:
95
+ desc = part
96
+
97
+ return name, dtype, choices, desc
98
+
99
+
100
+ class GLiNER2ONNXEngine:
101
+ """Cached wrapper around the monolithic GLiNER2 ONNX model.
102
+
103
+ The model and tokenizer are loaded once and reused for the life of the
104
+ process. ``onnxruntime``'s ``InferenceSession.run`` is thread-safe, so the
105
+ engine can serve concurrent requests (the caller bounds concurrency).
106
+ """
107
+
108
+ def __init__(self, model_id: str, device: str = "cpu") -> None:
109
+ self._model_id = model_id
110
+ self._device = device
111
+ self._session: Optional[ort.InferenceSession] = None
112
+ self._tokenizer: Optional[Tokenizer] = None
113
+ self._load_error: Optional[str] = None
114
+
115
+ # ------------------------------------------------------------------ #
116
+ # Lifecycle
117
+ # ------------------------------------------------------------------ #
118
+
119
+ def load(self) -> None:
120
+ """Load and cache the ONNX session + tokenizer. Idempotent."""
121
+ if self._session is not None:
122
+ return
123
+ try:
124
+ model_dir = self._resolve_model_dir()
125
+ tokenizer_path = os.path.join(model_dir, "tokenizer.json")
126
+ model_path = os.path.join(model_dir, "model.onnx")
127
+ if not os.path.exists(tokenizer_path):
128
+ raise FileNotFoundError(f"tokenizer.json not found in {model_dir}")
129
+ if not os.path.exists(model_path):
130
+ raise FileNotFoundError(f"model.onnx not found in {model_dir}")
131
+
132
+ providers = ["CPUExecutionProvider"]
133
+ if self._device == "cuda":
134
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
135
+
136
+ self._tokenizer = Tokenizer.from_file(tokenizer_path)
137
+ self._session = ort.InferenceSession(model_path, providers=providers)
138
+ self._load_error = None
139
+ except Exception as exc: # noqa: BLE001
140
+ self._load_error = str(exc)
141
+ logger.exception("GLiNER2 ONNX model load failed")
142
+ raise
143
+
144
+ def unload(self) -> None:
145
+ """Release the cached session + tokenizer."""
146
+ self._session = None
147
+ self._tokenizer = None
148
+ self._load_error = None
149
+
150
+ def is_loaded(self) -> bool:
151
+ return self._session is not None
152
+
153
+ def load_error(self) -> Optional[str]:
154
+ return self._load_error
155
+
156
+ @property
157
+ def model_id(self) -> str:
158
+ return self._model_id
159
+
160
+ @property
161
+ def device(self) -> str:
162
+ return self._device
163
+
164
+ def _resolve_model_dir(self) -> str:
165
+ """Return a local directory containing ``model.onnx`` + ``tokenizer.json``."""
166
+ if os.path.isdir(self._model_id):
167
+ return self._model_id
168
+ return snapshot_download(self._model_id)
169
+
170
+ def _ensure_loaded(self) -> None:
171
+ if self._session is None:
172
+ self.load()
173
+
174
+ # ------------------------------------------------------------------ #
175
+ # Inference
176
+ # ------------------------------------------------------------------ #
177
+
178
+ def extract_entities(self, text: str, labels: List[str], threshold: float = 0.5) -> Dict[str, Any]:
179
+ """Zero-shot entity extraction. Returns ``{"entities": {label: [texts]}}``."""
180
+ self._ensure_loaded()
181
+ result: Dict[str, List[str]] = {label: [] for label in labels}
182
+
183
+ text = self._normalize_text(text)
184
+ words = self._split_words(text)
185
+ if not words:
186
+ return {"entities": result}
187
+
188
+ word_strings = [w for w, _, _ in words]
189
+ start_map = [w[1] for w in words]
190
+ end_map = [w[2] for w in words]
191
+
192
+ schema_tokens = self._build_schema_tokens("entities", list(labels), E_TOKEN, {})
193
+ feeds = self._build_feeds(schema_tokens, word_strings)
194
+ grid = self._session.run(None, feeds)[0][0]
195
+
196
+ text_len = len(word_strings)
197
+ for i, label in enumerate(labels):
198
+ spans = self._find_spans(grid[i], threshold, text_len, text, start_map, end_map)
199
+ result[label] = self._format_spans(spans)
200
+
201
+ return {"entities": result}
202
+
203
+ def extract_json(self, text: str, structure: Dict[str, Any], threshold: float = 0.5) -> Dict[str, Any]:
204
+ """Structured JSON extraction. Returns ``{parent: [object]}``."""
205
+ self._ensure_loaded()
206
+ if not structure:
207
+ return {}
208
+
209
+ schema_obj = self._build_structure_schema(structure)
210
+ schema = schema_obj.build()
211
+ prefix = self._build_classification_prefix(schema)
212
+
213
+ text = self._normalize_text(text)
214
+ words = self._split_words(text)
215
+ if not words:
216
+ return {parent: [] for parent in schema.get("json_structures", []) for parent in parent}
217
+
218
+ word_strings = [w for w, _, _ in words]
219
+ start_map = [w[1] for w in words]
220
+ end_map = [w[2] for w in words]
221
+ text_tokens = prefix + word_strings
222
+ num_prefix = len(prefix)
223
+ text_len = len(word_strings)
224
+
225
+ json_descs = schema.get("json_descriptions", {})
226
+ result: Dict[str, Any] = {}
227
+
228
+ for struct in schema.get("json_structures", []):
229
+ for parent, fields in struct.items():
230
+ field_names = list(fields.keys())
231
+ schema_tokens = self._build_schema_tokens(
232
+ parent, field_names, C_TOKEN, json_descs.get(parent, {})
233
+ )
234
+ feeds = self._build_feeds(schema_tokens, text_tokens)
235
+ grid = self._session.run(None, feeds)[0][0]
236
+
237
+ obj: Dict[str, Any] = {}
238
+ for fname in schema_obj._field_orders.get(parent, field_names):
239
+ fidx = field_names.index(fname)
240
+ full = grid[fidx]
241
+ meta = schema_obj._field_metadata.get(f"{parent}.{fname}", {})
242
+ dtype = meta.get("dtype", "list")
243
+ field_threshold = (
244
+ meta.get("threshold") if meta.get("threshold") is not None else threshold
245
+ )
246
+
247
+ fval = fields[fname]
248
+ if isinstance(fval, dict) and "choices" in fval:
249
+ obj[fname] = self._decode_choice_field(
250
+ full, prefix, fval["choices"], num_prefix, field_threshold, dtype
251
+ )
252
+ else:
253
+ scores = full[num_prefix:num_prefix + text_len]
254
+ spans = self._find_spans(scores, field_threshold, text_len, text, start_map, end_map)
255
+ if dtype == "list":
256
+ obj[fname] = self._dedup_texts(self._format_spans(spans))
257
+ else:
258
+ obj[fname] = spans[0][0] if spans else None
259
+
260
+ instances = [obj] if any(v is not None and v != [] for v in obj.values()) else []
261
+ result[parent] = instances
262
+
263
+ return result
264
+
265
+ # ------------------------------------------------------------------ #
266
+ # Schema construction (mirrors gliner2.processor / gliner2 engine)
267
+ # ------------------------------------------------------------------ #
268
+
269
+ @staticmethod
270
+ def _normalize_text(text: str) -> str:
271
+ """Append a period so the model sees the same input gliner2 would."""
272
+ if text and not text.endswith((".", "!", "?")):
273
+ return text + "."
274
+ return text if text else "."
275
+
276
+ @staticmethod
277
+ def _split_words(text: str) -> List[Tuple[str, int, int]]:
278
+ """Split text into lowercased ``(word, char_start, char_end)`` tuples."""
279
+ lowered = text.lower()
280
+ return [(m.group(), m.start(), m.end()) for m in _WORD_RE.finditer(lowered)]
281
+
282
+ def _build_structure_schema(self, structure: Dict[str, Any]) -> Schema:
283
+ """Build a gliner2 ``Schema`` object from a route ``structure`` dict."""
284
+ schema = Schema()
285
+ for parent, specs in structure.items():
286
+ builder = schema.structure(parent)
287
+ for spec in specs:
288
+ name, dtype, choices, desc = _parse_field_spec(spec)
289
+ builder.field(name, dtype=dtype, choices=choices, description=desc)
290
+ builder._auto_finish()
291
+ return schema
292
+
293
+ @staticmethod
294
+ def _build_classification_prefix(schema: Dict[str, Any]) -> List[str]:
295
+ """Build the classification (choice) prefix tokens prepended to the text."""
296
+ prefix_tokens: List[str] = []
297
+ for struct in schema.get("json_structures", []):
298
+ for parent, fields in struct.items():
299
+ cls_fields = [
300
+ (fname, fval) for fname, fval in fields.items()
301
+ if isinstance(fval, dict) and "value" in fval and "choices" in fval
302
+ ]
303
+ inner: List[str] = []
304
+ for fname, fval in cls_fields:
305
+ choices = fval["choices"]
306
+ choice_tokens: List[str] = []
307
+ for i, c in enumerate(choices):
308
+ if i > 0:
309
+ choice_tokens.append("|")
310
+ choice_tokens.append(c)
311
+ inner.extend([fname, "("] + choice_tokens + [")", ","])
312
+ if inner:
313
+ inner = inner[:-1]
314
+ prefix_tokens.extend(["(", f"{parent}:", *inner, ")"])
315
+ return prefix_tokens
316
+
317
+ @staticmethod
318
+ def _build_schema_tokens(
319
+ parent: str,
320
+ fields: List[str],
321
+ child_token: str,
322
+ descriptions: Dict[str, str],
323
+ ) -> List[str]:
324
+ """Build a schema token sequence like gliner2's ``_transform_schema``."""
325
+ prompt_str = parent
326
+ descs = [(lbl, d) for lbl, d in descriptions.items() if lbl in fields]
327
+ for lbl, d in descs:
328
+ prompt_str += f" {DESC_TOKEN} {lbl}: {d}"
329
+ tokens = ["(", P_TOKEN, prompt_str, "("]
330
+ for field in fields:
331
+ tokens.extend([child_token, field])
332
+ tokens.extend([")", ")"])
333
+ return tokens
334
+
335
+ # ------------------------------------------------------------------ #
336
+ # ONNX inputs
337
+ # ------------------------------------------------------------------ #
338
+
339
+ def _build_feeds(self, schema_tokens: List[str], text_tokens: List[str]) -> Dict[str, np.ndarray]:
340
+ """Build the five ONNX input tensors for one schema + text pair."""
341
+ combined = schema_tokens + [SEP_TEXT] + text_tokens
342
+ encoding = self._tokenizer.encode(combined, is_pretokenized=True, add_special_tokens=False)
343
+ token_ids = encoding.ids
344
+ word_ids = encoding.word_ids
345
+
346
+ input_ids = np.array([token_ids], dtype=np.int64)
347
+ attention_mask = np.ones((1, len(token_ids)), dtype=np.int64)
348
+
349
+ # Number of words before the text segment: schema words + [SEP_TEXT].
350
+ num_schema_words = len(schema_tokens) + 1
351
+
352
+ word_first: Dict[int, int] = {}
353
+ for tok_pos, wid in enumerate(word_ids):
354
+ if wid is not None and wid not in word_first:
355
+ word_first[wid] = tok_pos
356
+
357
+ text_positions = np.array(
358
+ [word_first[num_schema_words + i] for i in range(len(text_tokens))], dtype=np.int64
359
+ )
360
+ schema_positions = np.array(
361
+ [word_first[i] for i, tok in enumerate(schema_tokens) if tok in SPECIAL_TOKENS],
362
+ dtype=np.int64,
363
+ )
364
+
365
+ num_words = len(text_positions)
366
+ spans = []
367
+ for start in range(num_words):
368
+ for width in range(1, MAX_WIDTH + 1):
369
+ end = start + width
370
+ if end <= num_words:
371
+ spans.append((start, end - 1))
372
+ else:
373
+ spans.append((0, 0))
374
+ span_idx = np.array(spans, dtype=np.int64).reshape(1, -1, 2)
375
+
376
+ return {
377
+ "input_ids": input_ids,
378
+ "attention_mask": attention_mask,
379
+ "text_positions": text_positions,
380
+ "schema_positions": schema_positions,
381
+ "span_idx": span_idx,
382
+ }
383
+
384
+ # ------------------------------------------------------------------ #
385
+ # Decoding (mirrors gliner2's span/choice decoding on the ONNX scores)
386
+ # ------------------------------------------------------------------ #
387
+
388
+ @staticmethod
389
+ def _find_spans(
390
+ scores: np.ndarray,
391
+ threshold: float,
392
+ text_len: int,
393
+ text: str,
394
+ start_map: List[int],
395
+ end_map: List[int],
396
+ ) -> List[Tuple[str, float, int, int]]:
397
+ """Find spans above threshold. Returns ``(text, confidence, start, end)``."""
398
+ valid = np.argwhere(scores >= threshold)
399
+ spans: List[Tuple[str, float, int, int]] = []
400
+ for start, width in valid:
401
+ end = start + width + 1
402
+ if 0 <= start < text_len and end <= text_len:
403
+ try:
404
+ char_start = start_map[start]
405
+ char_end = end_map[end - 1]
406
+ text_span = text[char_start:char_end].strip()
407
+ except (IndexError, KeyError):
408
+ continue
409
+ if text_span:
410
+ spans.append((text_span, float(scores[start, width]), char_start, char_end))
411
+ return spans
412
+
413
+ @staticmethod
414
+ def _format_spans(spans: List[Tuple[str, float, int, int]]) -> List[str]:
415
+ """Score-sort + overlap removal, matching gliner2's ``_format_spans``."""
416
+ if not spans:
417
+ return []
418
+ sorted_spans = sorted(spans, key=lambda x: x[1], reverse=True)
419
+ selected: List[Tuple[str, float, int, int]] = []
420
+ for text, conf, start, end in sorted_spans:
421
+ overlap = any(not (end <= s[2] or start >= s[3]) for s in selected)
422
+ if not overlap:
423
+ selected.append((text, conf, start, end))
424
+ return [s[0] for s in selected]
425
+
426
+ @staticmethod
427
+ def _dedup_texts(values: List[str]) -> List[str]:
428
+ """Dedup a list of extracted texts (case-insensitive), matching gliner2."""
429
+ unique: List[str] = []
430
+ seen = set()
431
+ for v in values:
432
+ if v and v.lower() not in seen:
433
+ seen.add(v.lower())
434
+ unique.append(v)
435
+ return unique
436
+
437
+ @staticmethod
438
+ def _find_choice_idx(choice: str, tokens: List[str]) -> int:
439
+ """Index of a choice token inside the classification prefix tokens."""
440
+ choice_lower = choice.lower()
441
+ for i, tok in enumerate(tokens):
442
+ if tok.lower() == choice_lower or choice_lower in tok.lower():
443
+ return i
444
+ return -1
445
+
446
+ def _decode_choice_field(
447
+ self,
448
+ full: np.ndarray,
449
+ prefix: List[str],
450
+ choices: List[str],
451
+ num_prefix: int,
452
+ threshold: float,
453
+ dtype: str,
454
+ ) -> Any:
455
+ """Score the classification-prefix choice tokens (gliner2 semantics)."""
456
+ prefix_scores = full[:num_prefix, 0]
457
+
458
+ if dtype == "list":
459
+ selected: List[str] = []
460
+ seen = set()
461
+ for choice in choices:
462
+ if choice in seen:
463
+ continue
464
+ idx = self._find_choice_idx(choice, prefix)
465
+ if 0 <= idx < len(prefix_scores):
466
+ score = float(prefix_scores[idx])
467
+ if score >= threshold:
468
+ selected.append(choice)
469
+ seen.add(choice)
470
+ return selected
471
+
472
+ best: Optional[str] = None
473
+ best_score = -1.0
474
+ for choice in choices:
475
+ idx = self._find_choice_idx(choice, prefix)
476
+ if 0 <= idx < len(prefix_scores):
477
+ score = float(prefix_scores[idx])
478
+ if score > best_score:
479
+ best_score = score
480
+ best = choice
481
+ return best if best and best_score >= threshold else None
app/services/gliner_service.py CHANGED
@@ -9,29 +9,32 @@ loaded on first use if startup warm-up failed). Inference is CPU-bound and is
9
  dispatched on the shared thread pool by the route layer; a bounded semaphore
10
  serializes concurrent forwards on the shared model instance so a burst of
11
  requests cannot oversubscribe the CPU or the model's memory buffers.
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
15
 
16
- import contextlib
17
- import io
18
  import threading
19
  import time
20
  from typing import Any, Dict, List, Optional
21
 
22
  from app.config import get_settings
23
  from app.core.logger import get_logger
 
24
 
25
  logger = get_logger(__name__)
26
 
27
- DEFAULT_MODEL_ID = "fastino/gliner2-base-v1"
28
  DEFAULT_DEVICE = "cpu"
29
  DEFAULT_MAX_CONCURRENT = 2
30
  DEFAULT_MAX_CONTENT_LENGTH = 100_000
31
 
32
 
33
  class GLiNERService:
34
- """Cached, concurrency-bounded wrapper around a GLiNER2 model instance."""
35
 
36
  def __init__(
37
  self,
@@ -44,7 +47,7 @@ class GLiNERService:
44
  self._device = device
45
  self._max_content_length = max_content_length
46
  self._max_concurrent = max(1, max_concurrent)
47
- self._model: Any = None
48
  self._lock = threading.Lock()
49
  self._semaphore = threading.BoundedSemaphore(self._max_concurrent)
50
  self._load_error: Optional[str] = None
@@ -54,7 +57,7 @@ class GLiNERService:
54
  # ------------------------------------------------------------------ #
55
 
56
  def load_model(self) -> None:
57
- """Load and cache the GLiNER2 model. Idempotent and thread-safe."""
58
  if self.is_loaded():
59
  return
60
  with self._lock:
@@ -62,33 +65,29 @@ class GLiNERService:
62
  return
63
  t0 = time.perf_counter()
64
  try:
65
- from gliner2 import GLiNER2
66
-
67
- # GLiNER2 prints an emoji config banner to stdout on load, which
68
- # crashes consoles with a non-UTF-8 encoding (e.g. Windows cp1252).
69
- # Swallow it so loading works everywhere.
70
- with contextlib.redirect_stdout(io.StringIO()):
71
- self._model = GLiNER2.from_pretrained(self._model_id, map_location=self._device)
72
  self._load_error = None
73
  logger.info(
74
- "GLiNER2 model loaded and cached (%s, device=%s) in %.2fs",
75
  self._model_id,
76
  self._device,
77
  time.perf_counter() - t0,
78
  )
79
  except Exception as exc: # noqa: BLE001
80
  self._load_error = str(exc)
81
- logger.exception("GLiNER2 model load failed")
82
  raise
83
 
84
  def unload(self) -> None:
85
- """Release the cached model (frees ~1.3 GB). Used at shutdown."""
86
  with self._lock:
87
- self._model = None
88
  self._load_error = None
89
 
90
  def is_loaded(self) -> bool:
91
- return self._model is not None
92
 
93
  def load_error(self) -> Optional[str]:
94
  return self._load_error
@@ -110,16 +109,16 @@ class GLiNERService:
110
  # ------------------------------------------------------------------ #
111
 
112
  def extract_json(self, text: str, structure: Dict[str, Any], threshold: float = 0.5) -> Dict[str, Any]:
113
- """Structured JSON extraction with the cached local model."""
114
  self._ensure_loaded()
115
  with self._semaphore:
116
- return self._model.extract_json(text, structure, threshold=threshold)
117
 
118
  def extract_entities(self, text: str, labels: List[str], threshold: float = 0.5) -> Dict[str, Any]:
119
- """Zero-shot entity extraction with the cached local model."""
120
  self._ensure_loaded()
121
  with self._semaphore:
122
- return self._model.extract_entities(text, labels, threshold=threshold)
123
 
124
  def _ensure_loaded(self) -> None:
125
  if not self.is_loaded():
@@ -134,3 +133,144 @@ gliner_service = GLiNERService(
134
  max_concurrent=_settings.gliner_max_concurrent,
135
  max_content_length=_settings.gliner_max_content_length,
136
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  dispatched on the shared thread pool by the route layer; a bounded semaphore
10
  serializes concurrent forwards on the shared model instance so a burst of
11
  requests cannot oversubscribe the CPU or the model's memory buffers.
12
+
13
+ Inference backend: the ONNX export ``lion-ai/gliner2-base-v1-onnx`` running on
14
+ ONNX Runtime via ``GLiNER2ONNXEngine`` (see ``gliner_onnx.py``). This removes
15
+ the PyTorch dependency from the extraction hot path.
16
  """
17
 
18
  from __future__ import annotations
19
 
 
 
20
  import threading
21
  import time
22
  from typing import Any, Dict, List, Optional
23
 
24
  from app.config import get_settings
25
  from app.core.logger import get_logger
26
+ from app.services.gliner_onnx import GLiNER2ONNXEngine
27
 
28
  logger = get_logger(__name__)
29
 
30
+ DEFAULT_MODEL_ID = "lion-ai/gliner2-base-v1-onnx"
31
  DEFAULT_DEVICE = "cpu"
32
  DEFAULT_MAX_CONCURRENT = 2
33
  DEFAULT_MAX_CONTENT_LENGTH = 100_000
34
 
35
 
36
  class GLiNERService:
37
+ """Cached, concurrency-bounded wrapper around the GLiNER2 ONNX engine."""
38
 
39
  def __init__(
40
  self,
 
47
  self._device = device
48
  self._max_content_length = max_content_length
49
  self._max_concurrent = max(1, max_concurrent)
50
+ self._engine: Optional[GLiNER2ONNXEngine] = None
51
  self._lock = threading.Lock()
52
  self._semaphore = threading.BoundedSemaphore(self._max_concurrent)
53
  self._load_error: Optional[str] = None
 
57
  # ------------------------------------------------------------------ #
58
 
59
  def load_model(self) -> None:
60
+ """Load and cache the GLiNER2 ONNX model. Idempotent and thread-safe."""
61
  if self.is_loaded():
62
  return
63
  with self._lock:
 
65
  return
66
  t0 = time.perf_counter()
67
  try:
68
+ engine = GLiNER2ONNXEngine(model_id=self._model_id, device=self._device)
69
+ engine.load()
70
+ self._engine = engine
 
 
 
 
71
  self._load_error = None
72
  logger.info(
73
+ "GLiNER2 ONNX model loaded and cached (%s, device=%s) in %.2fs",
74
  self._model_id,
75
  self._device,
76
  time.perf_counter() - t0,
77
  )
78
  except Exception as exc: # noqa: BLE001
79
  self._load_error = str(exc)
80
+ logger.exception("GLiNER2 ONNX model load failed")
81
  raise
82
 
83
  def unload(self) -> None:
84
+ """Release the cached model. Used at shutdown."""
85
  with self._lock:
86
+ self._engine = None
87
  self._load_error = None
88
 
89
  def is_loaded(self) -> bool:
90
+ return self._engine is not None
91
 
92
  def load_error(self) -> Optional[str]:
93
  return self._load_error
 
109
  # ------------------------------------------------------------------ #
110
 
111
  def extract_json(self, text: str, structure: Dict[str, Any], threshold: float = 0.5) -> Dict[str, Any]:
112
+ """Structured JSON extraction with the cached local ONNX model."""
113
  self._ensure_loaded()
114
  with self._semaphore:
115
+ return self._engine.extract_json(text, structure, threshold=threshold)
116
 
117
  def extract_entities(self, text: str, labels: List[str], threshold: float = 0.5) -> Dict[str, Any]:
118
+ """Zero-shot entity extraction with the cached local ONNX model."""
119
  self._ensure_loaded()
120
  with self._semaphore:
121
+ return self._engine.extract_entities(text, labels, threshold=threshold)
122
 
123
  def _ensure_loaded(self) -> None:
124
  if not self.is_loaded():
 
133
  max_concurrent=_settings.gliner_max_concurrent,
134
  max_content_length=_settings.gliner_max_content_length,
135
  )
136
+
137
+
138
+ # =============================================================================
139
+ # DEPRECATED -- PyTorch (``gliner2`` / torch) backend.
140
+ # -----------------------------------------------------------------------------
141
+ # Kept for reference and easy rollback / later fallback implementation. The old
142
+ # implementation loaded ``fastino/gliner2-base-v1`` via the ``gliner2`` PyTorch
143
+ # library, which pulled torch into the extraction hot path and required
144
+ # redirecting stdout to swallow the emoji config banner.
145
+ #
146
+ # To restore the PyTorch backend:
147
+ # 1. Set GLINER_MODEL back to a PyTorch repo (e.g. fastino/gliner2-base-v1)
148
+ # in config/.env/.env.example and the Dockerfile.
149
+ # 2. Un-comment the whole block below and delete/rename the ONNX
150
+ # ``GLiNERService`` above (or point the module-level ``gliner_service``
151
+ # instantiation at the deprecated class).
152
+ # =============================================================================
153
+ # from __future__ import annotations
154
+ #
155
+ # import contextlib
156
+ # import io
157
+ # import threading
158
+ # import time
159
+ # from typing import Any, Dict, List, Optional
160
+ #
161
+ # from app.config import get_settings
162
+ # from app.core.logger import get_logger
163
+ #
164
+ # logger = get_logger(__name__)
165
+ #
166
+ # DEFAULT_MODEL_ID = "fastino/gliner2-base-v1"
167
+ # DEFAULT_DEVICE = "cpu"
168
+ # DEFAULT_MAX_CONCURRENT = 2
169
+ # DEFAULT_MAX_CONTENT_LENGTH = 100_000
170
+ #
171
+ #
172
+ # class GLiNERService:
173
+ # """Cached, concurrency-bounded wrapper around a GLiNER2 model instance."""
174
+ #
175
+ # def __init__(
176
+ # self,
177
+ # model_id: str = DEFAULT_MODEL_ID,
178
+ # device: str = DEFAULT_DEVICE,
179
+ # max_concurrent: int = DEFAULT_MAX_CONCURRENT,
180
+ # max_content_length: int = DEFAULT_MAX_CONTENT_LENGTH,
181
+ # ) -> None:
182
+ # self._model_id = model_id
183
+ # self._device = device
184
+ # self._max_content_length = max_content_length
185
+ # self._max_concurrent = max(1, max_concurrent)
186
+ # self._model: Any = None
187
+ # self._lock = threading.Lock()
188
+ # self._semaphore = threading.BoundedSemaphore(self._max_concurrent)
189
+ # self._load_error: Optional[str] = None
190
+ #
191
+ # # ------------------------------------------------------------------ #
192
+ # # Lifecycle
193
+ # # ------------------------------------------------------------------ #
194
+ #
195
+ # def load_model(self) -> None:
196
+ # """Load and cache the GLiNER2 model. Idempotent and thread-safe."""
197
+ # if self.is_loaded():
198
+ # return
199
+ # with self._lock:
200
+ # if self.is_loaded():
201
+ # return
202
+ # t0 = time.perf_counter()
203
+ # try:
204
+ # from gliner2 import GLiNER2
205
+ #
206
+ # # GLiNER2 prints an emoji config banner to stdout on load, which
207
+ # # crashes consoles with a non-UTF-8 encoding (e.g. Windows cp1252).
208
+ # # Swallow it so loading works everywhere.
209
+ # with contextlib.redirect_stdout(io.StringIO()):
210
+ # self._model = GLiNER2.from_pretrained(self._model_id, map_location=self._device)
211
+ # self._load_error = None
212
+ # logger.info(
213
+ # "GLiNER2 model loaded and cached (%s, device=%s) in %.2fs",
214
+ # self._model_id,
215
+ # self._device,
216
+ # time.perf_counter() - t0,
217
+ # )
218
+ # except Exception as exc: # noqa: BLE001
219
+ # self._load_error = str(exc)
220
+ # logger.exception("GLiNER2 model load failed")
221
+ # raise
222
+ #
223
+ # def unload(self) -> None:
224
+ # """Release the cached model (frees ~1.3 GB). Used at shutdown."""
225
+ # with self._lock:
226
+ # self._model = None
227
+ # self._load_error = None
228
+ #
229
+ # def is_loaded(self) -> bool:
230
+ # return self._model is not None
231
+ #
232
+ # def load_error(self) -> Optional[str]:
233
+ # return self._load_error
234
+ #
235
+ # @property
236
+ # def model_id(self) -> str:
237
+ # return self._model_id
238
+ #
239
+ # @property
240
+ # def device(self) -> str:
241
+ # return self._device
242
+ #
243
+ # @property
244
+ # def max_content_length(self) -> int:
245
+ # return self._max_content_length
246
+ #
247
+ # # ------------------------------------------------------------------ #
248
+ # # Inference (called on the shared thread pool)
249
+ # # ------------------------------------------------------------------ #
250
+ #
251
+ # def extract_json(self, text: str, structure: Dict[str, Any], threshold: float = 0.5) -> Dict[str, Any]:
252
+ # """Structured JSON extraction with the cached local model."""
253
+ # self._ensure_loaded()
254
+ # with self._semaphore:
255
+ # return self._model.extract_json(text, structure, threshold=threshold)
256
+ #
257
+ # def extract_entities(self, text: str, labels: List[str], threshold: float = 0.5) -> Dict[str, Any]:
258
+ # """Zero-shot entity extraction with the cached local model."""
259
+ # self._ensure_loaded()
260
+ # with self._semaphore:
261
+ # return self._model.extract_entities(text, labels, threshold=threshold)
262
+ #
263
+ # def _ensure_loaded(self) -> None:
264
+ # if not self.is_loaded():
265
+ # self.load_model()
266
+ #
267
+ #
268
+ # _settings = get_settings()
269
+ #
270
+ # gliner_service = GLiNERService(
271
+ # model_id=_settings.gliner_model,
272
+ # device=_settings.gliner_device,
273
+ # max_concurrent=_settings.gliner_max_concurrent,
274
+ # max_content_length=_settings.gliner_max_content_length,
275
+ # )
276
+ # =============================================================================
requirements.txt CHANGED
@@ -45,7 +45,12 @@ zvec==0.4.0
45
  # Semantic routing (aurelio-labs) used by the /semantic-router/route endpoint
46
  semantic-router==0.1.16
47
  # Local on-device model powering the /json/feature-extract route (no external AI).
 
 
 
48
  gliner2==1.3.2
 
 
49
  sentence-transformers==3.4.1
50
  transformers==4.50.2
51
  torch==2.5.1
 
45
  # Semantic routing (aurelio-labs) used by the /semantic-router/route endpoint
46
  semantic-router==0.1.16
47
  # Local on-device model powering the /json/feature-extract route (no external AI).
48
+ # gliner2==1.3.2 stays for its torch-free Schema builder; the extraction hot
49
+ # path now runs the ONNX export (lion-ai/gliner2-base-v1-onnx) via onnxruntime.
50
+ # DEPRECATED: the PyTorch backend used gliner2's GLiNER2.from_pretrained(torch).
51
  gliner2==1.3.2
52
+ # Direct tokenizer loading for the ONNX GLiNER2 backend (gliner_onnx.py).
53
+ tokenizers==0.21.1
54
  sentence-transformers==3.4.1
55
  transformers==4.50.2
56
  torch==2.5.1