# GLiNER2 + Extractor — RAG Integration Patterns

**Status:** Reference guide for v1.2.0
**Audience:** Developers building RAG pipelines on extractor output

---

## 1. Chunk Record Anatomy

Every chunk record emitted by `extractor run --mode chunks --entities` looks like this:

```json
{
  "id": "ch_8f3a2b1c",
  "element_type": "composite:chunk",
  "text": "Tim Cook joined Apple in 1998...",
  "section_path": ["Executive Profiles"],
  "token_count": 487,
  "chunk_metadata": {
    "entity_types": ["date", "organization", "person"],
    "has_table": false,
    "source_elements": ["el_7f3a", "el_7f3b"]
  },
  "entities": [
    {"entity_type": "person", "text": "Tim Cook", "score": 0.94,
     "canonical_id": "person:tim-cook", "canonical_text": "Tim Cook",
     "source_element_id": "el_7f3a"},
    {"entity_type": "organization", "text": "Apple", "score": 0.97,
     "canonical_id": "organization:apple", "canonical_text": "Apple",
     "source_element_id": "el_7f3a"}
  ],
  "source_sha256": "e3b0c...",
  "source_filename": "annual_report.pdf"
}
```

The `chunk_metadata.entity_types` flat list enables fast payload filtering without deserializing `entities[]`.

---

## 2. Vector Store Ingestion Patterns

### 2.1 Qdrant (recommended for entity-filtered RAG)

Qdrant's nested payload filtering maps directly to extractor's entity schema.

```python
from qdrant_client import QdrantClient
from qdrant_client.models import (
    VectorParams, Distance, PointStruct,
    Filter, FieldCondition, MatchAny, MatchValue
)
import json

client = QdrantClient(url="http://localhost:6333")

# Collection setup
client.create_collection(
    collection_name="docs",
    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

# Ingest
def ingest_chunks(jsonl_path: str, embedder):
    points = []
    with open(jsonl_path) as f:
        for line in f:
            chunk = json.loads(line)
            if chunk["element_type"] != "composite:chunk":
                continue

            vector = embedder.embed(chunk["text"])

            # Flatten entities for Qdrant payload
            payload = {
                "text": chunk["text"],
                "section_path": chunk.get("section_path", []),
                "source_filename": chunk["source_filename"],
                "entity_types": chunk["chunk_metadata"].get("entity_types", []),
                "entities": [
                    {
                        "type": e["entity_type"],
                        "text": e["text"],
                        "canonical_id": e.get("canonical_id"),
                        "canonical_text": e.get("canonical_text"),
                    }
                    for e in chunk.get("entities", [])
                ],
            }
            points.append(PointStruct(id=chunk["id"], vector=vector, payload=payload))

    client.upsert(collection_name="docs", points=points)
```

**Entity-filtered retrieval:**
```python
# Find chunks mentioning Tim Cook (by canonical_id, catches all surface forms)
results = client.search(
    collection_name="docs",
    query_vector=query_embedding,
    query_filter=Filter(
        must=[
            FieldCondition(
                key="entities[].canonical_id",
                match=MatchValue(value="person:tim-cook")
            )
        ]
    ),
    limit=10,
)

# Find chunks containing any person + organization (co-occurrence filter)
results = client.search(
    collection_name="docs",
    query_vector=query_embedding,
    query_filter=Filter(
        must=[
            FieldCondition(
                key="entity_types",
                match=MatchAny(any=["person", "organization"])
            )
        ]
    ),
    limit=10,
)
```

### 2.2 Weaviate

Weaviate supports nested object arrays. Map `entities` to a cross-referenced object class.

```python
import weaviate

client = weaviate.Client("http://localhost:8080")

# Schema
client.schema.create({
    "classes": [
        {
            "class": "Chunk",
            "properties": [
                {"name": "text", "dataType": ["text"]},
                {"name": "sourceFilename", "dataType": ["text"]},
                {"name": "entityTypes", "dataType": ["text[]"]},
                {"name": "entities", "dataType": ["object[]"],
                 "nestedProperties": [
                     {"name": "entityType", "dataType": ["text"]},
                     {"name": "text", "dataType": ["text"]},
                     {"name": "canonicalId", "dataType": ["text"]},
                 ]},
            ],
        }
    ]
})

# Ingest
def ingest_chunks(jsonl_path: str, embedder):
    with open(jsonl_path) as f:
        for line in f:
            chunk = json.loads(line)
            if chunk["element_type"] != "composite:chunk":
                continue
            client.data_object.create(
                class_name="Chunk",
                data_object={
                    "text": chunk["text"],
                    "sourceFilename": chunk["source_filename"],
                    "entityTypes": chunk["chunk_metadata"].get("entity_types", []),
                    "entities": [
                        {
                            "entityType": e["entity_type"],
                            "text": e["text"],
                            "canonicalId": e.get("canonical_id", ""),
                        }
                        for e in chunk.get("entities", [])
                    ],
                },
                vector=embedder.embed(chunk["text"]),
            )
```

**Query:**
```python
result = (
    client.query
    .get("Chunk", ["text", "sourceFilename"])
    .with_where({
        "path": ["entities", "Chunk", "canonicalId"],
        "operator": "Equal",
        "valueText": "person:tim-cook",
    })
    .with_near_vector({"vector": query_embedding})
    .with_limit(10)
    .do()
)
```

### 2.3 Pinecone

Pinecone metadata is flat key-value. Serialize entity canonical IDs as a string list.

```python
import pinecone
import json

index = pinecone.Index("docs")

def ingest_chunks(jsonl_path: str, embedder):
    vectors = []
    with open(jsonl_path) as f:
        for line in f:
            chunk = json.loads(line)
            if chunk["element_type"] != "composite:chunk":
                continue

            # Pinecone metadata: flat lists only
            canonical_ids = [
                e["canonical_id"]
                for e in chunk.get("entities", [])
                if e.get("canonical_id")
            ]
            canonical_texts = [
                e.get("canonical_text", e["text"])
                for e in chunk.get("entities", [])
                if not e.get("is_reference", False)
            ]

            vectors.append({
                "id": chunk["id"],
                "values": embedder.embed(chunk["text"]),
                "metadata": {
                    "text": chunk["text"][:1000],  # Pinecone metadata size limit
                    "source": chunk["source_filename"],
                    "entity_types": chunk["chunk_metadata"].get("entity_types", []),
                    "canonical_ids": canonical_ids,
                    "canonical_texts": canonical_texts,
                },
            })

    index.upsert(vectors=vectors)

# Query with filter
results = index.query(
    vector=query_embedding,
    filter={"canonical_ids": {"$in": ["person:tim-cook"]}},
    top_k=10,
    include_metadata=True,
)
```

### 2.4 pgvector

pgvector works well with a normalized relational schema. Store entities in a separate table for efficient `JOIN` filtering.

```sql
-- Schema
CREATE TABLE chunks (
    id TEXT PRIMARY KEY,
    text TEXT NOT NULL,
    source_filename TEXT,
    section_path TEXT[],
    token_count INT,
    embedding vector(1536)
);

CREATE TABLE chunk_entities (
    chunk_id TEXT REFERENCES chunks(id) ON DELETE CASCADE,
    entity_type TEXT NOT NULL,
    surface_text TEXT NOT NULL,
    canonical_id TEXT,
    canonical_text TEXT,
    score FLOAT,
    PRIMARY KEY (chunk_id, canonical_id, entity_type)
);

CREATE INDEX ON chunk_entities (canonical_id);
CREATE INDEX ON chunk_entities (entity_type);
```

```python
import psycopg2, json

conn = psycopg2.connect("postgresql://localhost/rag")

def ingest_chunks(jsonl_path: str, embedder):
    with conn.cursor() as cur, open(jsonl_path) as f:
        for line in f:
            chunk = json.loads(line)
            if chunk["element_type"] != "composite:chunk":
                continue
            embedding = embedder.embed(chunk["text"])
            cur.execute(
                "INSERT INTO chunks VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING",
                (chunk["id"], chunk["text"], chunk["source_filename"],
                 chunk.get("section_path", []), chunk.get("token_count"),
                 embedding),
            )
            for ent in chunk.get("entities", []):
                if ent.get("is_reference"):
                    continue  # skip pronouns from relational store
                cur.execute(
                    "INSERT INTO chunk_entities VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING",
                    (chunk["id"], ent["entity_type"], ent["text"],
                     ent.get("canonical_id"), ent.get("canonical_text"), ent["score"]),
                )
    conn.commit()
```

**Entity-filtered ANN query:**
```sql
SELECT c.id, c.text,
       c.embedding <=> $1::vector AS distance
FROM chunks c
JOIN chunk_entities ce ON ce.chunk_id = c.id
WHERE ce.canonical_id = 'person:tim-cook'
ORDER BY distance
LIMIT 10;
```

---

## 2b. Graph Database: Kuzu (Consumer-Side — Not Part of Extractor)

> **Note:** Everything in this section is **consumer-side code** — patterns for developers building
> downstream RAG systems that consume extractor's JSONL output. Extractor itself has no graph DB
> dependency and outputs plain JSONL files. Kuzu, NetworkX, and Neo4j are all optional tools for
> downstream analytics; they are never installed or imported by the extractor package.

## 2c. Building the Co-occurrence Graph (Consumer-Side Example)

For storing the entity co-occurrence graph persistently, **Kuzu** is the recommended choice: embedded (no server), Apache 2.0, Cypher queries, columnar storage. NetworkX is used for construction; Kuzu for persistence and querying.

```python
def export_to_kuzu(G: nx.Graph, db_path: str) -> None:
    import kuzu
    db = kuzu.Database(db_path)
    conn = kuzu.Connection(db)
    conn.execute("""
        CREATE NODE TABLE IF NOT EXISTS Entity (
            node_id STRING PRIMARY KEY,
            entity_type STRING,
            text STRING,
            doc_frequency INT64,
            chunk_count INT64
        )
    """)
    conn.execute("""
        CREATE REL TABLE IF NOT EXISTS CO_OCCURS (
            FROM Entity TO Entity,
            weight DOUBLE,
            chunk_count INT64
        )
    """)
    for nid, data in G.nodes(data=True):
        conn.execute(
            "MERGE (e:Entity {node_id: $nid}) "
            "SET e.entity_type=$etype, e.text=$text, "
            "e.doc_frequency=$df, e.chunk_count=$cc",
            {"nid": nid, "etype": data["entity_type"], "text": data["text"],
             "df": data["doc_frequency"], "cc": data["chunk_count"]},
        )
    for a, b, data in G.edges(data=True):
        conn.execute(
            "MATCH (a:Entity {node_id: $a}), (b:Entity {node_id: $b}) "
            "MERGE (a)-[r:CO_OCCURS]->(b) SET r.weight=$w, r.chunk_count=$cc",
            {"a": a, "b": b, "w": float(data["weight"]), "cc": data["weight"]},
        )
```

Use Neo4j instead if you need RBAC, live replication, or the Bloom visualizer. NetworkX alone is sufficient for corpora under ~5M edges.

---

## 3. Hybrid Search: Dense + Sparse + Entity Filter

The most effective retrieval pattern for entity-rich documents combines three signals:

```
score = α * dense_score + β * bm25_score + γ * entity_match_boost
```

### Implementation with Qdrant sparse vectors

```python
from qdrant_client.models import SparseVector, NamedSparseVector

# At ingest: compute BM25 sparse vector (e.g. via rank_bm25 or fastembed)
from fastembed import SparseTextEmbedding
sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")

def get_sparse(text: str) -> SparseVector:
    result = list(sparse_model.embed([text]))[0]
    return SparseVector(indices=result.indices.tolist(), values=result.values.tolist())

# Ingest with both dense and sparse
client.upsert(
    collection_name="docs",
    points=[
        PointStruct(
            id=chunk["id"],
            vector={
                "dense": dense_embedding,
                "sparse": get_sparse(chunk["text"]),
            },
            payload=payload,
        )
    ],
)

# Hybrid query: dense + sparse + entity pre-filter
from qdrant_client.models import Prefetch, FusionQuery, Fusion

results = client.query_points(
    collection_name="docs",
    prefetch=[
        Prefetch(
            query=NamedSparseVector(name="sparse", vector=get_sparse(query_text)),
            using="sparse",
            limit=50,
        ),
    ],
    query=dense_embedding,
    using="dense",
    query_filter=Filter(
        must=[
            FieldCondition(
                key="entity_types",
                match=MatchAny(any=extracted_entity_types),  # from query NER
            )
        ]
    ),
    limit=10,
)
```

### RRF Fusion with Entity Boost

```python
from collections import defaultdict

def reciprocal_rank_fusion(
    *ranked_lists: list[tuple[str, float]],
    k: int = 60,
) -> list[tuple[str, float]]:
    """Fuse N ranked lists. k=60 is empirically robust across domains."""
    scores: dict[str, float] = defaultdict(float)
    for ranked in ranked_lists:
        for rank, (chunk_id, _) in enumerate(ranked, start=1):
            scores[chunk_id] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

def entity_boost_rrf(
    rrf_results: list[tuple[str, float]],
    chunk_store: dict,
    query_entities: list[str],  # normalized entity texts
    boost: float = 0.05,
) -> list[tuple[str, float]]:
    """Additive boost for exact entity text match. Keeps RRF as dominant signal."""
    qe_lower = {e.lower() for e in query_entities}
    boosted = []
    for chunk_id, score in rrf_results:
        chunk = chunk_store.get(chunk_id, {})
        chunk_texts = {e.get("text", "").lower() for e in chunk.get("entities", [])}
        if chunk_texts & qe_lower:
            score += boost
        boosted.append((chunk_id, score))
    return sorted(boosted, key=lambda x: x[1], reverse=True)
```

### Query-time entity extraction

Extract entities from the user query before retrieval to drive metadata filters:

```python
from extractor.ner import NERStep

ner = NERStep()

def rag_query(query_text: str, embedder, vector_store):
    # Extract entities from query
    query_entities = ner.extract_text(query_text)  # returns list[EntityAnnotation]

    canonical_ids = [e.canonical_id for e in query_entities if e.canonical_id]
    entity_types = list({e.entity_type for e in query_entities})

    query_embedding = embedder.embed(query_text)

    # Build filter from query entities
    must_conditions = []
    if canonical_ids:
        must_conditions.append(
            FieldCondition(
                key="entities[].canonical_id",
                match=MatchAny(any=canonical_ids),
            )
        )

    results = vector_store.search(
        query_vector=query_embedding,
        query_filter=Filter(must=must_conditions) if must_conditions else None,
        limit=10,
    )
    return results
```

---

## 4. GraphRAG / Knowledge Graph from Entity Co-occurrence

Extract a co-occurrence graph from extractor output. Nodes = canonical entities, edges = co-occurrence within a chunk.

```python
from collections import defaultdict
import json
import networkx as nx

def build_entity_graph(jsonl_path: str) -> nx.Graph:
    G = nx.Graph()
    edge_weights: dict[tuple[str,str], int] = defaultdict(int)

    with open(jsonl_path) as f:
        for line in f:
            chunk = json.loads(line)
            if chunk["element_type"] != "composite:chunk":
                continue

            entities = [
                e for e in chunk.get("entities", [])
                if e.get("canonical_id") and not e.get("is_reference")
            ]

            # Add nodes
            for ent in entities:
                if not G.has_node(ent["canonical_id"]):
                    G.add_node(ent["canonical_id"],
                               entity_type=ent["entity_type"],
                               canonical_text=ent.get("canonical_text", ent["text"]))

            # Add edges for every pair co-occurring in the same chunk
            seen = list({e["canonical_id"] for e in entities})
            for i in range(len(seen)):
                for j in range(i + 1, len(seen)):
                    a, b = sorted([seen[i], seen[j]])
                    edge_weights[(a, b)] += 1

    for (a, b), weight in edge_weights.items():
        G.add_edge(a, b, weight=weight)

    return G

# Example: find all organizations co-occurring with Tim Cook
G = build_entity_graph("output.jsonl")
neighbors = [
    (n, G.nodes[n]["canonical_text"], G[n]["person:tim-cook"]["weight"])
    for n in G.neighbors("person:tim-cook")
    if G.nodes[n]["entity_type"] == "organization"
]
neighbors.sort(key=lambda x: x[2], reverse=True)
```

### LangChain integration

```python
from langchain_community.graphs import NetworkxEntityGraph
from langchain.chains import GraphQAChain
from langchain_anthropic import ChatAnthropic

G = build_entity_graph("output.jsonl")
graph = NetworkxEntityGraph(graph=G)

chain = GraphQAChain.from_llm(
    llm=ChatAnthropic(model="claude-sonnet-4-6"),
    graph=graph,
    verbose=True,
)
result = chain.invoke({"query": "Which organizations is Tim Cook associated with?"})
```

---

## 5. Cross-Document Entity Aggregation

Use `canonical_id` to aggregate mentions across a corpus without re-running NER.

```python
from collections import defaultdict
import json

def aggregate_entity_mentions(jsonl_paths: list[str]) -> dict:
    """
    Returns: canonical_id -> {
        canonical_text, entity_type,
        mention_count, chunk_ids, source_files, surface_forms
    }
    """
    agg: dict[str, dict] = defaultdict(lambda: {
        "canonical_text": None,
        "entity_type": None,
        "mention_count": 0,
        "chunk_ids": set(),
        "source_files": set(),
        "surface_forms": set(),
    })

    for path in jsonl_paths:
        with open(path) as f:
            for line in f:
                chunk = json.loads(line)
                if chunk["element_type"] != "composite:chunk":
                    continue
                for ent in chunk.get("entities", []):
                    if not ent.get("canonical_id") or ent.get("is_reference"):
                        continue
                    cid = ent["canonical_id"]
                    rec = agg[cid]
                    rec["canonical_text"] = ent.get("canonical_text", ent["text"])
                    rec["entity_type"] = ent["entity_type"]
                    rec["mention_count"] += 1
                    rec["chunk_ids"].add(chunk["id"])
                    rec["source_files"].add(chunk["source_filename"])
                    rec["surface_forms"].add(ent["text"])

    # Serialize sets
    return {
        cid: {**rec,
              "chunk_ids": list(rec["chunk_ids"]),
              "source_files": list(rec["source_files"]),
              "surface_forms": list(rec["surface_forms"])}
        for cid, rec in agg.items()
    }

# Usage
mentions = aggregate_entity_mentions(["q1.jsonl", "q2.jsonl", "q3.jsonl"])
# mentions["organization:apple"]["mention_count"] == 847
# mentions["organization:apple"]["source_files"] == ["q1.jsonl", "q2.jsonl"]
# mentions["organization:apple"]["surface_forms"] == {"Apple", "Apple Inc.", "AAPL"}
```

---

## 6. LangChain Document Loader Adapter

Wrap extractor's JSONL output as LangChain `Document` objects with entity metadata in `metadata`.

```python
from langchain_core.documents import Document
from langchain_core.document_loaders import BaseLoader
import json
from pathlib import Path

class ExtractorChunkLoader(BaseLoader):
    """LangChain loader for extractor --mode chunks --entities JSONL output."""

    def __init__(self, jsonl_path: str):
        self.jsonl_path = Path(jsonl_path)

    def lazy_load(self):
        with open(self.jsonl_path) as f:
            for line in f:
                chunk = json.loads(line)
                if chunk["element_type"] != "composite:chunk":
                    continue

                # Flatten entity metadata for LangChain metadata dict
                entities_flat = [
                    {
                        "type": e["entity_type"],
                        "text": e.get("canonical_text", e["text"]),
                        "canonical_id": e.get("canonical_id"),
                    }
                    for e in chunk.get("entities", [])
                    if not e.get("is_reference")
                ]

                yield Document(
                    page_content=chunk["text"],
                    metadata={
                        "chunk_id": chunk["id"],
                        "source": chunk["source_filename"],
                        "section_path": " > ".join(chunk.get("section_path", [])),
                        "token_count": chunk.get("token_count"),
                        "entity_types": chunk["chunk_metadata"].get("entity_types", []),
                        "entities": entities_flat,
                    },
                )

# Usage with LangChain retriever
from langchain_community.vectorstores import Qdrant
from langchain_openai import OpenAIEmbeddings

loader = ExtractorChunkLoader("annual_report_chunks.jsonl")
docs = list(loader.lazy_load())

vectorstore = Qdrant.from_documents(
    docs,
    OpenAIEmbeddings(),
    url="http://localhost:6333",
    collection_name="docs",
)
```

---

**Important:** For LlamaIndex nodes, set `excluded_llm_metadata_keys=["entities"]` to prevent entity JSON (potentially 600+ bytes per chunk) from polluting the LLM synthesis prompt while keeping it available for metadata filtering:

```python
node = TextNode(
    text=chunk["text"],
    metadata={"entities": entities, "entity_types": entity_types, ...},
    excluded_llm_metadata_keys=["entities"],       # not sent to LLM
    excluded_embed_metadata_keys=["entities", "source_sha256"],  # not embedded
)
```

---

## 7. LlamaIndex Node Parser Adapter

```python
from llama_index.core.schema import TextNode
import json

def extractor_jsonl_to_nodes(jsonl_path: str) -> list[TextNode]:
    nodes = []
    with open(jsonl_path) as f:
        for line in f:
            chunk = json.loads(line)
            if chunk["element_type"] != "composite:chunk":
                continue

            node = TextNode(
                id_=chunk["id"],
                text=chunk["text"],
                metadata={
                    "source": chunk["source_filename"],
                    "section_path": chunk.get("section_path", []),
                    "entity_types": chunk["chunk_metadata"].get("entity_types", []),
                    "canonical_ids": [
                        e["canonical_id"]
                        for e in chunk.get("entities", [])
                        if e.get("canonical_id") and not e.get("is_reference")
                    ],
                },
            )
            nodes.append(node)
    return nodes
```

---

## 8. Query Routing via Entity Density

Use entity density to route queries between LOCAL (entity-specific) and GLOBAL (thematic) retrieval strategies — analogous to GraphRAG's routing.

```python
def route_query(query_text: str, ner_step) -> str:
    """Returns 'local', 'global', or 'hybrid'."""
    entities = ner_step.extract_text(query_text)
    named = [e for e in entities if not e.is_reference and e.score >= 0.7]

    if len(named) >= 2:
        return "local"   # entity-specific: use entity filter + ANN
    elif len(named) == 1:
        return "hybrid"  # one entity: filter + broad ANN
    else:
        return "global"  # thematic: pure ANN, no entity filter

# Usage
route = route_query("What did Tim Cook say about Apple's margins?", ner)
# -> "local" (2 named entities: Tim Cook, Apple)

route = route_query("What are the main risk factors?", ner)
# -> "global" (0 named entities)
```

---

## 9. Sibling Chunk Expansion via Shared Entity

When a retrieved chunk mentions an entity, expand the result set with sibling chunks that share the same canonical entity — enables "all mentions of X" retrieval.

```python
def expand_by_entity(
    initial_results: list[dict],
    vector_store,
    max_expansion: int = 5,
) -> list[dict]:
    """Expand result set with chunks sharing canonical entities."""
    canonical_ids = set()
    for r in initial_results:
        for ent in r.get("payload", {}).get("entities", []):
            if ent.get("canonical_id"):
                canonical_ids.add(ent["canonical_id"])

    if not canonical_ids:
        return initial_results

    expanded = vector_store.scroll(
        collection_name="docs",
        scroll_filter=Filter(
            must=[
                FieldCondition(
                    key="entities[].canonical_id",
                    match=MatchAny(any=list(canonical_ids)),
                )
            ]
        ),
        limit=max_expansion,
    )

    # Merge without duplicates
    seen_ids = {r["id"] for r in initial_results}
    for r in expanded[0]:
        if r.id not in seen_ids:
            initial_results.append({"id": r.id, "payload": r.payload})
            seen_ids.add(r.id)

    return initial_results
```

---

## 10. Pattern Summary

| Pattern | Entity field used | Value |
|---|---|---|
| Pre-retrieval payload filter | `entities[].canonical_id` | Eliminates irrelevant chunks before ANN; 35–90% noise reduction |
| Hybrid search boost | `chunk_metadata.entity_types` | Fast type-level pre-filter with dense+sparse fusion |
| Query-time entity extraction | (new NER call on query) | Drives canonical_id filter from user query |
| Knowledge graph construction | `canonical_id` + co-occurrence | GraphRAG LOCAL/GLOBAL routing, relationship queries |
| Cross-document aggregation | `canonical_id` | Corpus-level mention counts, multi-file entity index |
| Sibling chunk expansion | `entities[].canonical_id` | "All mentions of X" retrieval without re-embedding |
| Query routing | Entity density from query NER | LOCAL vs GLOBAL strategy selection |
| LangChain/LlamaIndex adapter | All fields | Drop-in integration with existing LangChain chains |
