Skip to content

Fix GLiREL relation extraction: pass token-index ner, not character offsets#152

Open
samarthbanodia wants to merge 1 commit into
neo4j-labs:mainfrom
samarthbanodia:fix/glirel-token-index-ner
Open

Fix GLiREL relation extraction: pass token-index ner, not character offsets#152
samarthbanodia wants to merge 1 commit into
neo4j-labs:mainfrom
samarthbanodia:fix/glirel-token-index-ner

Conversation

@samarthbanodia

Copy link
Copy Markdown

Fix GLiREL relation extraction: pass token-index ner, not character offsets (silent 0 relations on glirel ≥ 1.x)

Fixes #151.

Summary

GLiRELExtractor (and therefore GLiNERWithRelationsExtractor) produces zero relations when used
exactly as documented, and raises a pydantic.ValidationError at non-default thresholds. The cause
is the entity→GLiREL format conversion: it passes GLiNER character offsets in a 4-tuple where GLiREL
expects token indices in a 3-tuple, and it reads GLiREL's token-list head_text/tail_text as if
they were strings. As a side effect, any pipeline that falls back to an LLM when local relation
extraction yields nothing silently routes all relation extraction to the LLM.

This PR makes the conversion correct, joins the token-slice output, and adds a small
huggingface_hub compatibility shim so GLiREL.from_pretrained loads on current glirel/hub
versions. Verified end-to-end (see Verification).

Environment where this reproduces

neo4j-agent-memory 0.4.0
glirel              1.2.1
gliner              0.2.26
transformers        5.1.0
huggingface_hub     1.18.0
pydantic            2.13.4
torch               2.12.0+cpu

neo4j-agent-memory advertises the GLiREL feature but declares no glirel version pin (only
gliner), so there is no "supported glirel version" that avoids this — current resolutions get 1.2.1.

Symptoms

  • GLiNERWithRelationsExtractor.for_poleo().extract(text) → entities found, relations == [] at the
    default threshold=0.5. No error is raised — it just looks like "no relations in this text."
  • At lower thresholds the same call crashes: GLiREL returns head_text/tail_text as token-list
    slices (lists), which are passed straight into ExtractedRelation(source=..., target=...) whose
    fields are typed strpydantic.ValidationError.
  • On glirel ≤ 1.2.1 + recent huggingface_hub, the model won't even load:
    TypeError: GLiREL._from_pretrained() missing 2 required keyword-only arguments: 'proxies' and 'resume_download'.

Root cause

GLiRELExtractor._entities_to_glirel_format builds entries as:

[entity.start_pos or 0, entity.end_pos or len(entity.name), entity.type, entity.name]   # CHAR offsets, 4-tuple

but glirel.GLiREL.predict_relations(text, labels, ..., ner=...) (signature
(self, text, labels, flat_ner=True, threshold=0.5, ner=None, ground_truth_relations=None, top_k=-1))
expects ner as [[start_token, end_token, label], ...] — TOKEN indices aligned to the token list
passed as text. Character offsets interpreted as token indices land out of range / span the whole
sentence, so predictions are empty (silently dropped at 0.5) or degenerate. And the returned
head_text/tail_text are token-list slices, not strings, so building ExtractedRelation from them
either validates oddly or raises.

The fix

src/neo4j_agent_memory/extraction/gliner_extractor.py:

  1. _entities_to_glirel_format(entities, token_spans) now maps each entity's character span to the
    range of tokens it overlaps and emits the correct 3-tuple [start_token, end_token, label].
    Entities whose span can't be resolved are skipped (fewer relations, never a crash).
  2. _extract_relations_sync tokenizes with the same regex GLiREL uses
    (\w+(?:[-_]\w+)*|\S) so token indices line up, passes top_k=1 (best label per pair), and
    joins the token-slice head_text/tail_text into strings before constructing
    ExtractedRelation. Empty/self-pair predictions are dropped and duplicates de-duplicated.
    Bonus: this removes the hard dependency on a loaded spaCy model (en_core_web_sm) for GLiREL
    tokenization — a common silent failure point. (_tokenize/nlp are retained, just unused here.)
  3. _apply_glirel_hub_compat_shim() is called in the lazy model property: it injects safe
    defaults for the proxies/resume_download kwargs that newer huggingface_hub no longer passes,
    so from_pretrained works on glirel ≤ 1.2.1. Idempotent; no-op if glirel isn't importable.

The only signature change is to the private _entities_to_glirel_format (now takes token_spans).

Reproduction

The runnable repro is in #151. On the unpatched package it prints relations: 0 for every
sentence; with this PR applied it prints real relations. Run:

pip install "neo4j-agent-memory==0.4.0" "glirel==1.2.1" gliner spacy
python repro_glirel_bug.py

Verification (controlled, with an independent oracle)

A controlled before/after harness loads the model once and compares the shipped vs patched
GLiRELExtractor.extract_relations on the same GLiNER entities at the same thresholds, against an
independent reference implementation of the correct GLiREL contract. Results
(jackboyla/glirel-large-v0, CPU):

relations @ 0.5 @ 0.3
Shipped (before) 0 across all 5 test sentences 0 / crash
Patched (after) 12, e.g. John Smith —WORKS_AT→ Acme Corporation, Questar —LOCATED_IN→ Salt Lake City, Sarah Lee —CEO_OF→ Enron no crash

The patched output matches the independent reference implementation set-for-set on every sentence,
and the model loads via the in-property shim with no external patching. Same model, same entities, same
thresholds — the only variable was the conversion code.

Notes

  • The relations GLiREL returns zero-shot are still noisy/symmetric (it scores both directions and can
    mislabel); that's a model-quality matter, separate from this correctness fix. This PR is strictly
    about "the pipeline now returns relations at all" rather than "always to the LLM."
  • Affected: 0.4.0 (current). The conversion bug predates the huggingface_hub kwarg change; the load
    shim is needed only on newer huggingface_hub + glirel ≤ 1.2.1.

…ffsets

predict_relations expects ner as [[start_token, end_token, label], ...] aligned
to the token list, and returns head_text/tail_text as token-list slices. The
previous conversion passed GLiNER character offsets in a 4-tuple and read the
slices as strings, yielding 0 relations at the default threshold and a pydantic
ValidationError at lower ones. Map char spans to token indices, join the slice
output, drop the hard spaCy-tokenization dependency, and add a from_pretrained
compat shim for newer huggingface_hub.
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

@samarthbanodia is attempting to deploy a commit to the lyonwj's projects Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GLiREL relation extraction returns 0 relations (ner passed as char offsets, not token indices)

1 participant