xthor's picture
Update README.md
17bc59a verified
|
Raw
History Blame Contribute Delete
13.1 kB
---
license: apache-2.0
library_name: sentence-transformers
base_model: Qwen/Qwen3-Embedding-0.6B
datasets:
- xthor/Qwen3-Embedding-GraphQL-v1
pipeline_tag: sentence-similarity
tags:
- sentence-transformers
- sentence-similarity
- feature-extraction
- graphql
- retrieval
- embeddings
- text-embeddings-inference
- qwen3
- gguf
language:
- en
---
# Qwen3-Embedding-0.6B-GraphQL
**An embedding model that maps a question in plain English to the GraphQL `Type.field` that answers it.** It's made for schema retrieval in LLM agent pipelines, and appears to be the first open-source embedding model trained for the job. General-purpose embedders, the usual choice, can't reliably tell apart the near-identical field names that fill a real schema, so retrieval suffers.
When an LLM agent has to query a GraphQL API, the hard part isn't writing the query. It's grounding the query in a schema that's often thousands of fields wide and won't fit in a context window. The usual fix is RAG over the schema: embed every `Type.field`, retrieve the handful relevant to the question, and feed only those to the agent. General-purpose embedders struggle here because **real schemas reuse field names everywhere**. Dozens of types carry a `description`, an `author`, a `createdAt`, a `state`. Knowing the field name isn't enough; you have to know *whose* field it is.
This is a fine-tune of [`Qwen/Qwen3-Embedding-0.6B`](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B) trained for that one task: **owner-type disambiguation when field names collide.** The agent gets the right coordinate in context instead of a same-named field on the wrong type. At 0.6B it runs on CPU or alongside the agent's own model.
The payoff, on held-out queries against schemas never seen in training:
| metric | base | **tuned** | lift |
|---------------|-------|-----------|--------|
| exact_match@1 | 0.090 | **0.229** | +155% |
| recall@10 | 0.215 | **0.435** | +102% |
| mrr@10 | 0.121 | **0.285** | +135% |
On an external benchmark against the full [GitHub GraphQL schema](https://github.com/octokit/graphql-schema) (6,342 coordinates, 52 queries, never seen in training), using `sdl` formatting:
| metric | base | **tuned** | lift |
|-----------|-------|-----------|--------|
| MRR | 0.511 | **0.723** | +41% |
| R@1 | 0.385 | **0.615** | +60% |
| R@5 | 0.654 | **0.865** | +32% |
| P95 rank | 53 | **40** | -25% |
Drop-in for any GraphQL-aware RAG, query builder, or schema search. Ships as SentenceTransformer weights and GGUF builds for `llama.cpp` / Ollama.
> **Important: how you format the corpus matters as much as the model.** Use SDL snippets or `dot_plus_gloss` formatting for best results. See [Embedding style comparison](#how-you-format-the-corpus-matters) for details.
---
## Inference
### SentenceTransformers
```python
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("xthor/Qwen3-Embedding-0.6B-GraphQL")
query = "What's the nightly rate for this room?"
# coordinates of Type.field pairs
coords = [
"Room.priceCents",
"RoomUpgradeOffer.priceCents",
"Ticket.priceCents",
]
q = model.encode(query, prompt_name="query")
c = model.encode(coords, prompt_name="document")
scores = (q @ c.T).tolist()
for coord, score in sorted(zip(coords, scores), key=lambda x: -x[1]):
print(f"{score:.3f} {coord}")
```
Two prompts are wired into the model and must be used for best results:
- `prompt_name="query"` for natural-language questions
- `prompt_name="document"` for GraphQL coordinate descriptions in the corpus
### Ollama
```sh
# pull one quantization (Q8_0 is a good default: near-lossless, ~650 MB)
hf download xthor/Qwen3-Embedding-0.6B-GraphQL model-q8_0.gguf --local-dir .
cat > Modelfile <<'EOF'
FROM ./model-q8_0.gguf
EOF
ollama create qwen3-graphql-embedder -f Modelfile
# OpenAI-compatible embeddings endpoint
curl -s http://localhost:11434/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3-graphql-embedder","input":"What is the nightly rate for this room?"}' \
| jq '.data[0].embedding'
```
### llama.cpp
```sh
hf download xthor/Qwen3-Embedding-0.6B-GraphQL model-q8_0.gguf --local-dir .
./llama-server -m model-q8_0.gguf --embedding --port 8080
# POST http://localhost:8080/embedding { "content": "..." }
```
### Available GGUF quantizations
| file | size | use case |
|----------------------|---------|-------------------------------------------------------|
| `model-f16.gguf` | ~1.2 GB | reference quality, parity with safetensors |
| `model-q8_0.gguf` | ~650 MB | near-lossless; recommended default |
| `model-q4_k_m.gguf` | ~400 MB | small footprint; accepts a minor quality trade-off |
---
## Results
223 held-out test queries · 28,893-coordinate corpus · 30% real SDLs (GitHub GHES, Saleor, Shopify, AniList) never seen in training.
| metric | baseline | **tuned (3 epochs)** | lift |
|-----------------|----------|----------------------|----------------|
| exact_match@1 | 0.090 | **0.229** | +0.139 (+155%) |
| recall@3 | 0.130 | **0.318** | +0.188 |
| recall@5 | 0.161 | **0.345** | +0.184 (+114%) |
| recall@10 | 0.215 | **0.435** | +0.220 (+102%) |
| mrr@10 | 0.121 | **0.285** | +0.164 |
| ndcg@10 | 0.143 | **0.320** | +0.177 |
![baseline vs tuned — headline metrics](images/metrics_bars.png)
![recall@k across the sweep](images/recall_at_k.png)
### Where the lift comes from
Direct questions (*"has my package shipped?"*, *"what's my total?"*) are already handled well by the base model. The gains come from **indirect questions** where the user names a concept rather than a field. Those require owner-type reasoning, and that's where the base model falls behind.
#### Example: rank 101 → 1
> *"I need to understand what commitments we have regarding support response times. Where can I find that info?"*
Correct target: `SlaPolicy.description`. The schema has **262 `.description` fields** (on `Incident`, `Issue`, `Resolution`, `SatisfactionSurvey`, …). The task is picking the right owner, not the right field name.
| | base | tuned |
|------------------------------------------|---------|---------|
| rank in full corpus (18,396 coordinates) | **101** | **1** |
| rank among 262 `.description` siblings | **12** | **1** |
| cosine(query, target) | 0.428 | 0.383 |
| cosine(query, base top-1 distractor) | 0.484 | 0.303 |
![SlaPolicy sibling cosines](images/sibling_cosines_sla.png)
The base model ranks `SatisfactionSurvey.description` and `Incident.description` above the target. The fine-tune demotes them: every wrong owner drops to 0.15–0.22 while the target becomes the top hit.
![SlaPolicy ranking ladder](images/ranking_ladder_sla.png)
#### Example: rank 5 → 1
> *"What's the nightly rate for this room?"*
Correct target: `Room.priceCents`. Six other `.priceCents` fields exist (upgrade offers, extensions, tickets).
| | base | tuned |
|------------------------------------------|----------------------------|---------|
| rank in full corpus | **5** | **1** |
| rank among 7 `.priceCents` siblings | **3** | **1** |
| cosine(query, target) | 0.51 | **0.61**|
| cosine(query, base top-1 distractor) | 0.55 (`RoomUpgradeOffer`) | 0.43 |
| margin to runner-up | –0.04 (target loses) | +0.12 |
![Room sibling cosines](images/sibling_cosines_room.png)
Even on a natural, direct question the base model picks the wrong owner (it ranks `RoomUpgradeOffer.priceCents` first). The fine-tune reverses the ordering and opens a clear margin.
![Room ranking ladder](images/ranking_ladder_room.png)
### Known limitations
1. **Formatting sensitivity.** With raw dot notation (`Type.field`), the fine-tune's R@1 is only 0.308 on the GitHub schema. Always use `sdl`, `dot_plus_gloss`, or `natural` formatting for the corpus.
2. **Same-owner wrong-field rate.** `same_owner_wrong_field_rate@1` rose from 0.063 to 0.103. The model picks the right owner type more often but occasionally lands on the wrong field within that type. The training signal rewards owner disambiguation; within-owner field disambiguation isn't targeted. The next iteration will add competition sets that share owner and differ by field.
3. **Tail regression with raw dot notation.** When using raw dot notation, the fine-tune's P95 rank (404) is worse than the base model's (123). The model becomes more confident: it either ranks the correct answer first or misses much harder. This is fully mitigated by using `sdl` (P95 40) or `dot_plus_gloss` (P95 41) formatting.
4. **Indirect queries.** Queries that don't name or allude to the owner type (e.g., *"get the README"* → `Repository.object`) remain hard for both models. The fine-tune does not improve on these.
![metric deltas](images/metrics_deltas.png)
### How you format the corpus matters
How you turn each `Type.field` coordinate into text before embedding it affects retrieval more than the fine-tune does. The benchmark below compares twelve formats on the [GitHub GraphQL schema](https://github.com/octokit/graphql-schema) (52 held-out queries):
![embedding style comparison](images/style_comparison.png)
**Use one of these two. They tie at the top:**
```text
# sdl: if you parse the schema (MRR 0.723)
type PullRequest { baseRefName: String! }
# dot_plus_gloss: string-only, no parsing needed (MRR 0.715)
PullRequest.baseRefName — the base ref name of a pull request
```
The cheap string-only gloss costs almost nothing versus full schema parsing, so reach for `dot_plus_gloss` unless you already have parsed types on hand. Whatever you do, **don't embed raw `Type.field` identifiers**. With `dot` formatting, MRR drops to 0.393 and the worst-case rank blows out 10x. The owner type is what carries the signal: drop it entirely and retrieval collapses to MRR ~0.05.
#### Full results
Each format is one way of rendering `PullRequest.baseRefName` into text before embedding (the **example** column shows exactly what). `P95` is the 95th-percentile rank, i.e. how badly the *worst* queries rank. Lower is better.
| format | example (`PullRequest.baseRefName` →) | base MRR | tuned MRR | P95 |
|------------------|----------------------------------------------|----------|-----------|------|
| `sdl` | `type PullRequest { baseRefName: String! }` | 0.511 | **0.723** | 40 |
| `dot_plus_gloss` | `PullRequest.baseRefName — the base ref name of a pull request` | 0.551 | **0.715** | 41 |
| `semantic` | `GraphQL field PullRequest.baseRefName. Owner type… Returns: String!…` | 0.368 | **0.659** | 39 |
| `field_first` | `base ref name (PullRequest)` | 0.571 | **0.652** | 70 |
| `natural` | `the base ref name field on PullRequest` | 0.420 | **0.578** | 119 |
| `arrow` | `PullRequest > base ref name` | 0.419 | **0.548** | 159 |
| `colon` | `PullRequest: base ref name` | 0.400 | **0.488** | 199 |
| `split_space` | `pull request base ref name` | 0.391 | **0.447** | 448 |
| `signature` | `PullRequest.baseRefName: String!` | 0.334 | **0.408** | 298 |
| `dot` | `PullRequest.baseRefName` (raw, no change) | 0.334 | **0.393** | 404 |
| `type_only` | `pull request` (field dropped, ablation) | 0.248 | 0.242 | 261 |
| `field_only` | `base ref name` (type dropped, ablation) | 0.063 | 0.045 | 3377 |
---
## Training
| run | epochs | batch | lr | loss |
|----------------|--------|-------|------|-------------|
| `qwen3` | 2 | 64 | 5e-5 | cached_mnrl |
| `qwen3-e3` | 3 | 64 | 5e-5 | cached_mnrl |
Both: `--max-seq-length 256`, 4 hard negatives per anchor, `bf16`, full fine-tune (no LoRA), single H100. Published checkpoint: **`qwen3-e3`**.
### Dataset
| split | rows |
|---------|--------|
| train | 4,788 |
| val | 94 |
| test | 223 |
| corpus | 28,893 |
Built from 7,626 raw seed pairs via world-leakage, per-row strict-leakage, and family-level semantic-dedup filters. The strict-leakage filter is aggressive on real-SDL queries, which is why val/test shrink to ~20% of raw.
---
## Citation
- Base model: [Qwen3-Embedding-0.6B](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B)
- GitHub Training [GitHub-Repo-train-data](https://github.com/ThoreKoritzius/graphql-embedding-model)
- License: Apache 2.0 (inherited from the base)