File size: 8,726 Bytes
b2dc8e7
2ab9aad
b2dc8e7
 
2ab9aad
 
 
 
 
b2dc8e7
 
2ab9aad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2dc8e7
2ab9aad
b2dc8e7
2ab9aad
b2dc8e7
2ab9aad
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
---
license: apache-2.0
base_model: unsloth/qwen3-4b-instruct-2507-unsloth-bnb-4bit
tags:
  - text2cypher
  - cypher
  - graph-database
  - unsloth
  - qwen3
---

# text2cypher_lora_v4_raw

A Qwen3-4B-Instruct-2507 fine-tune (LoRA, merged 16-bit) that turns a **natural-language question + a graph schema description** into a **Cypher query**. Trained on the full, unbalanced v4 dataset β€” 3,744 rows, 36 domains, 180 schemas β€” see [the training repo](https://github.com/BeastxD7/DocuPrism-Text2Cypher) for the full pipeline and `qa/v4_generation_tracking.md` for exactly how this dataset was built and audited.

## This model requires a specific prompt format β€” it will NOT work with a bare question

This is the single most important thing to know before using it. The model was trained to
expect the **graph schema in the system prompt**, not baked into the weights β€” that's what
lets one model handle arbitrary domains/schemas it's never seen, rather than being locked to
one. A generic chat message like `{"role": "user", "content": "Who are you?"}` (the default
HF "Use this model" snippet above) will just get you a generic base-Qwen answer β€” the
fine-tuning has nothing to activate on without a schema.

**Correct usage:**

```python
from transformers import AutoTokenizer, AutoModelForCausalLM

tokenizer = AutoTokenizer.from_pretrained("BeastxD/text2cypher_lora_v4_raw")
model = AutoModelForCausalLM.from_pretrained("BeastxD/text2cypher_lora_v4_raw", device_map="auto")

SYSTEM_PROMPT_TEMPLATE = (
    """You are a Cypher query generation assistant for a Neo4j graph database.

You are given a graph schema and a question in natural language. Use the
schema strictly - it is the only source of truth for what exists in the graph.

How to read the schema:
- 'Node properties' lists each node label together with its properties and
  their types (e.g. STRING, FLOAT, DATE, POINT). Some properties list
  example or available values - these show the kind of data to expect, not
  an exhaustive list to match against literally unless the question refers
  to one of them directly.
- 'The relationships' lists every valid pattern of how node labels connect,
  in the form (:LabelA)-[:REL_TYPE]->(:LabelB). This tells you both the
  relationship type name and its direction - respect the direction when you
  build your MATCH pattern.

How to map the question to the schema:
1. Find the node label(s) the question is really asking about (the subject
  and the target of the question).
2. Find the relationship path in the schema that connects those labels -
  questions often require traversing more than one relationship.
3. Identify any filters mentioned in the question (names, dates, categories,
  thresholds) and match them to the correct property on the correct label.
4. If the question asks for a count, total, average, minimum, maximum, or
  'top N', use the appropriate aggregation function and ORDER BY / LIMIT.

Rules:
- Use only labels, relationship types, and properties that literally appear
  in the schema below. Never invent one.
- Return ONLY the Cypher query - no explanation, no markdown fences, no
  comments.
- Return only the specific properties the question names. Return a whole
  node only when the question asks generally about an entity without naming
  particular attributes.
- When computing a single overall aggregate (an overall average, count, or
  sum), do not carry unrelated variables into the WITH that produces it -
  every non-aggregated variable in a WITH implicitly groups the aggregate by
  that variable, turning one intended overall result into one result per
  group.
- Before returning the query, check every relationship pattern you used against
  the schema's relationship list. Your arrow direction and label order must
  match one of the listed (:LabelA)-[:REL_TYPE]->(:LabelB) patterns exactly -
  if your pattern is the reverse of a listed one, you have the direction
  wrong and must flip it.
- For "highest", "lowest", "top N", "most/least" phrasing, select with
  ORDER BY <property> ASC|DESC LIMIT N rather than computing min()/max() and
  re-matching on equality - re-matching on equality returns every tied row
  instead of one deterministic answer.
- If a MATCH path can reach the same return value multiple times through
  multi-hop or branching traversal, use DISTINCT on it - unless the question
  specifically asks for a count or list per relationship/edge, in which case
  duplicates are the correct answer and DISTINCT must not be used.
- When the question asks about a status, state, count threshold, or yes/no
  condition ("accepted", "active", "at least one", "any", "some", "is X"),
  first check whether the relevant node has a property in the schema that
  directly represents that condition (a BOOLEAN, or a COUNT/INTEGER property
  already tracking it) and filter on it directly. Do not reconstruct the
  condition via a traversal or exists() check if a direct property already
  encodes it.
- If the property the question refers to (e.g. "type", "kind", "category")
  does not exist on the node you first match, do not traverse further away
  from it searching for a substitute property on a different node. Stay on
  the matched node and use its closest literal property (e.g. count distinct
  values of an existing identifying property on that same node) rather than
  inventing a multi-hop path to a loosely related property elsewhere.
- Return ONLY the Cypher query - no explanation, no markdown fences, no
  comments.\n\nSchema:\n{schema}"""
)

schema = """Nodes:
  Common properties:
    Β· id:STRING β€” Stable canonical entity identifier
    Β· name:STRING β€” Use FTS index (QUERY_FTS_INDEX) for fuzzy name lookups; CONTAINS as fallback
    Β· first_observed:DATE β€” Native DATE. Compare with DATE literals: WHERE n.first_observed >= DATE('2024-01-01')
    Β· last_observed:DATE β€” Native DATE. Use with first_observed for "active at date" checks
    Β· status:STRING β€” ACTIVE / ARCHIVED / UNCERTAIN

  Per-label descriptions and domain properties:
  (:Customer) β€” a customer who owns appliances and submits work orders
    Β· phone:STRING β€” primary contact phone number
    Β· preferred_contact_method:STRING β€” [Phone, Email, SMS]
  (:Appliance) β€” a specific appliance unit owned by a customer
    Β· appliance_type:STRING β€” [Refrigerator, Washer, Dryer, Dishwasher, Oven, HVAC]
    Β· brand:STRING β€” manufacturer brand name
    Β· model_number:STRING β€” manufacturer model number

Relationships:
  (:Customer)-[:OWNS]->(:Appliance) β€” customer owns the appliance"""

question = "What brand and model number does the appliance owned by customer 'Jane Doe' have?"

messages = [
    {"role": "system", "content": SYSTEM_PROMPT_TEMPLATE.format(schema=schema)},
    {"role": "user", "content": question},
]
inputs = tokenizer.apply_chat_template(
    messages, add_generation_prompt=True, tokenize=True,
    return_dict=True, return_tensors="pt",
).to(model.device)

outputs = model.generate(**inputs, max_new_tokens=250, do_sample=False)
print(tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True))
# -> MATCH (c:Customer {name: 'Jane Doe'})-[:OWNS]->(a:Appliance) RETURN a.brand, a.model_number
```


## Dataset

- 3,744 rows: 2,698 from v3 (already QA-audited, 0 known issues) + 1,046 new rows targeting 7 specific gap categories measured from v3's own eval failures (multi-property returns, min()/stdev(), time-bucketed grouping, ordinal-enum sort, relationship disambiguation, UNION, variable-length paths).
- Complexity distribution: 66.5% easy / 6.9% medium / 26.6% complex β€” this is the **raw**, unbalanced version. See `BeastxD/text2cypher_lora_v4_balanced` for a complexity-balanced alternative (997 easy / 260 medium / 995 complex, 2,252 rows) trained for direct comparison.
- Every row passed deterministic schema-grounding and relationship-direction checks before being included β€” see `common/validate_and_build.py` in the training repo.

## Eval results

Formal semantic-accuracy eval (via `common/semantic_rescore.py`, same methodology as v2/v3) has not been run against this checkpoint yet β€” check the training repo's `v4/evals_raw/` for results once available. Don't assume this outperforms v3 (~59-63% semantic accuracy) until that's actually measured.

## Training details

- Base: `unsloth/qwen3-4b-instruct-2507-unsloth-bnb-4bit`, 4-bit + rank-16 LoRA, targeting all attention + MLP projections.
- Trained on RunPod (RTX 5090), schema-grouped 80/10/10 train/val/heldout split.
- Same recipe as v3 (`v3/code/runpod/docuprism_lora_training_runpod.ipynb`), just repointed at the v4 dataset β€” see `v4/code/runpod/docuprism_lora_training_runpod_raw.ipynb`.