Skip to content

fluree doc: --model and --entities, grounded entity and relation extraction; hybrid search - #1764

Merged
bplatz merged 10 commits into
mainfrom
feature/doc-extract
Sep 3, 2026
Merged

fluree doc: --model and --entities, grounded entity and relation extraction; hybrid search#1764
bplatz merged 10 commits into
mainfrom
feature/doc-extract

Conversation

@bplatz

@bplatz bplatz commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Adds the extraction stage to fluree doc ingest, hybrid search to fluree doc search, and the fixes found by running both over real documents through a Fluree AI account. #1760 has merged, so this now targets main and CI sees the whole diff.

Extraction: --model and --entities

  • --entities <ledger|file[#Class]>, repeatable, is a gazetteer. Every label (skos:prefLabel/altLabel/hiddenLabel, rdfs:label, schema:name/legalName/alternateName) goes into one Aho-Corasick automaton; each chunk is scanned for the longest whole-word match, case-folded, and again over Snowball stems. A match is a doc:Mention on the chunk (NIF offsets, anchor, source element) whose nif:entity is the subject's own IRI from the source, so a dataset query over the entities ledger and the documents ledger joins with no mapping. Needs no model.
  • --model <ledger|file> is the ontology: rdfs:Class/owl:Class, rdf:Property/OWL properties, and SHACL shapes (sh:targetClasssh:propertysh:path/sh:class/sh:datatype), which is how a Fluree model usually declares its properties. It is rendered into the extraction system prompt (the same prompt the hosted extractor uses) and indexes the predicate gate. Per chunk the llm slot is asked about the text with the known entities found in it; entities resolve to gazetteer IRIs where a label matches and are minted under a name-derived IRI otherwise; an entity whose excerpt is not in the chunk is dropped; a new entity typed outside the ontology is kept flagged doc:offModel (or dropped with --drop-off-model); each relation predicate is judged valid, repaired (unambiguous label / local name / compact form / class-to-property) or rejected. Every relation is written reified with excerpt and verdict; --relations direct (default) also writes an edge for admitted ones, reified writes nodes only, off skips relations. A relation whose object names no entity carries it as a literal.
  • Sources load through the database itself: a file goes into a scratch in-memory ledger and is queried like any other. The target ledger's own minted doc:Entity nodes join the gazetteer on later runs (off-model ones excluded), so a corpus converges on one node per name.
  • Prompt control at three levels, as flags or in [doc.extraction] config so every run over a corpus makes the same ask: guidance text, and system_prompt / user_prompt template files keeping the {model}/{guidance}/{existing}/{document} slots.
  • Chunks are asked about --concurrency at a time (default 4). A chunk whose call fails keeps its gazetteer mentions; the document is written but not stamped as extracted, so the next run retries it while the cache answers for the rest.
  • Re-runs: the document node carries an extraction fingerprint (ontology, entity sources, model, prompts, relation mode, chunk sizes) and joins the unchanged check; mentions and relations are retracted with the document, minted entities are shared and kept, and an asserted edge is dropped only when no remaining relation supports it. A third cache keys each chunk's answer on the exact ask.

Search

  • --mode hybrid runs BM25 and vector and fuses them on calibrated scores: cosine as it is, BM25 as s / (s + 10) (3 → 0.23, 35 → 0.78), averaged over both methods with a missing method counting 0. Rank-only fusion would credit a weak first place as much as a strong one. Each hit prints every method's own score and rank. auto picks hybrid when the chunks carry embeddings, the full-text index exists and the query can be embedded.
  • The two lanes run concurrently, and a text search no longer waits on the account for a token it does not need.
  • The vector lane is flatrank, following feat(cli): fluree doc — Fluree Unstructured: documents into a searchable graph #1760: it scores every chunk's doc:embedding with cosineSimilarity, ordered and cut by LIMIT, rather than searching an HNSW graph source. Fusion is unchanged — it calibrates against a cosine in 0..1, which is exactly what flatrank produces, so the same scores arrive on the same scale. search_hits now takes the where-clauses that bind ?c and ?score plus an optional query vector, so both lanes build their own head and share the chunk join, and mode selection asks whether the chunks carry embeddings rather than whether a vector index exists.

Found by running real documents

  • The Fluree AI gateway's proxy path drops the Responses instructions field, so the extraction prompt never reached the model (every relation came back with null subject and object). The client sends the system prompt as a system-role message, which both the gateway and OpenAI accept; the extraction cache carries a wire version so prompt-less answers are not reused. The proxy-side fix is separate.
  • A dense drawing renders to a crop past the gateway's 6 MB request cap. Crops are fitted under 4.2 MB the way the hosted extractor does it (Lanczos, long edge 2048 → 1024 floor), sent untouched when they already fit.
  • A ledger written only by the CLI stayed at index t=0, and every later invocation replayed its commits into novelty: 824 ms for a trivial query and 2.1 s for a BM25 search on a 66-page statement, against 6 ms and 37 ms indexed. doc ingest now indexes the ledger after writing. The general case for CLI writers is Follow-up: Indexing strategy for CLI-only use: ledgers written by one-shot commands stay unindexed #1762.
  • Chunk defaults are now 1500–4000 characters and embedding batches 64, matching the hosted pipeline; the heading-boundary cut and start-of-chunk labelling stay.

Verification

  • cargo fmt --check, workspace clippy with all features and targets under -D warnings.
  • Workspace nextest: 11,713 pass. The three local failures are environmental: two it_iceberg_direct tests could not start LocalStack (Docker daemon wedged on the dev box) and the docs-embed test found two gitignored local design pages that no checkout has.
  • Unit coverage for the model gate, SHACL loading, scanner and stemming, resolution, fusion, crop fitting, the IRI gate and the caches; CLI integration tests against a stub model server cover known-IRI reuse, minting, hallucination drop, repair and rejection, unchanged skip, cache hit, edge cleanup on re-ingest, the failed-chunk retry, and the ledger index advancing.
  • After merging main: cargo test -p fluree-db-doc 53/53, the full fluree-db-cli suite 357/357, fmt and clippy clean on both crates.
  • End to end through a Fluree AI account (testco-18): the Fed combined financial statements (209 chunks: 2,549 mentions, 361 entities, 562 relations with 18 rejected), the magna engineering drawings with their SHACL model and supplier master (PRT-292191 depicts COATED SHAFT, change orders with NOC numbers, dates and signer edges), embeddings through the real OpenAI endpoint, and a string query answered by the chunk carrying Federal Reserve notes governed by FRA with its lien excerpt.

Addressed from review

  • Operator-supplied IRIs reaching a SPARQL update. delete_triples_update interpolated subjects, predicates and objects into <…>; gazetteer and model IRIs come from --entities / --model files, so one carrying > closed the brackets and spliced extra triples into a DELETE DATA. sparql_iri_safe applies the RFC 3987 IRIREF exclusions. An unsafe triple is skipped, not escaped — it is stored under that exact IRI, so a rewritten one would retract nothing and hide that it happened; the caller reports what it left in place. Shown non-vacuous by removing the filter, which produces DELETE DATA { <https://ex.org/x> <https://ex.org/p> <https://ex.org/o> . <urn:victim> … }.
  • Direct edges are claims, with evidence. The concept page now says so plainly: the gate decides which predicates may be written, never which statements are true, so any ingested document can attach an admitted-predicate edge to a gazetteer entity it names by label. Every edge has a reified node beside it carrying excerpt, verdict and source document, and --relations reified writes the evidence without the edges — the right mode for a corpus you do not trust.
  • The system-role message. Why it must stay that way is now a comment where someone would change it, not only in a description: it keeps working once the Fluree AI gateway honors instructions (both shapes are then served), while reverting it breaks any client pointed at a proxy that has not been updated — and the failure is a prompt-less call that still returns 200, with null subjects and objects in every relation.

Docs

New Unstructured concept page (entities and relations, with the cross-ledger query), and updates to the CLI reference, ingest and search pages (modes and how hybrid scores), configuration ([doc.extraction]), vocabulary, caching and the querying guide.

Follow-up: #1762

…action

`fluree doc ingest` can now say what a document is about, grounded on two
graphs the user already has:

- `--entities <ledger|file[#Class]>` (repeatable) is a gazetteer. Every
  label (skos:prefLabel/altLabel/hiddenLabel, rdfs:label, schema:name,
  schema:alternateName) goes into one Aho-Corasick automaton; each chunk
  is scanned for the longest whole-word match, case-folded, and again over
  Snowball stems. A match is a doc:Mention on the chunk (NIF offsets,
  anchor, source element) whose nif:entity is the subject's OWN IRI from
  the source, so a query across the entities ledger and the documents
  ledger joins with no mapping. Needs no model.

- `--model <ledger|file>` is an ontology. Its classes and properties are
  rendered into the extraction system prompt (the same prompt Fluree AI's
  hosted extraction uses) and index the predicate gate. Per chunk the llm
  slot is asked about the text with the known entities found in it; its
  entities resolve to gazetteer IRIs where a label matches and are minted
  under a name-derived IRI otherwise, an entity whose excerpt is not in
  the chunk is dropped, and each relation predicate is judged valid,
  repaired (unambiguous label, local name, compact form or
  class-to-property) or rejected. Every relation is written reified with
  excerpt and verdict; `--relations direct` (default) also writes an edge
  for admitted ones, `reified` writes nodes only, `off` skips relations.

Sources are loaded through the database itself: a file goes into a
scratch in-memory ledger and is queried like any other, so there is no
second RDF stack. The ledger's own minted doc:Entity nodes join the
gazetteer on later runs so a corpus converges on one node per name.

Re-runs: the document node carries an extraction fingerprint (ontology,
entity sources, model, guidance, relation mode) and joins the unchanged
check; mentions and relations are retracted with the document, minted
entities are shared and kept, and an asserted edge is dropped only when
no remaining relation supports it. A third cache keys each chunk's answer
on the exact ask.

The crop reader's wire code moves into a shared LlmClient (chat
completions or the gateway's Responses route with `instructions` and the
`extraction` intent); Chunk gains per-element spans so a mention names
the paragraph or cell it sits in.

Docs: a new Unstructured concept page, the CLI reference, vocabulary,
tiers, caching and querying pages updated. Tests: unit coverage for the
model gate, scanner, resolution and cache, and CLI integration tests
against a stub model server covering known-IRI reuse, minting,
hallucination drop, repair and rejection, unchanged skip, cache hit and
edge cleanup on re-ingest.
…odels, fit oversize crops

Three findings from the first runs over real documents through a Fluree
AI account.

The gateway's Responses route drops the `instructions` field, so the
extraction system prompt (ontology, field names) never reached the model:
every relation parsed with null subject and object, and entity types were
free-form. A system-role message with string content does get through,
so LlmClient sends the system prompt that way; OpenAI accepts both. The
extraction cache key carries a wire version so answers cached before this
are not reused.

A Fluree model declares its properties through SHACL: node shapes with
sh:targetClass, property shapes with sh:path, sh:class or sh:datatype,
sh:name and sh:description. The model loader now reads those alongside
rdf:Property and OWL declarations (magna: 16 classes, 119 properties).

A dense drawing renders to a crop past the gateway's 6 MB request cap.
Crops are now fitted under 4.2 MB the way the hosted extractor does it:
Lanczos downscale of the long edge from 2048 px to a 1024 px floor, sent
untouched when they already fit, cached on the original pixels.

Also: schema:legalName joins the label predicates (supplier masters use
it), and a `#Class` scope under an unknown prefix is refused with a hint
to write the full IRI rather than failing inside the query.
… chunking joins the unchanged check

Default chunks are now 1500 to 4000 characters, what Fluree AI's hosted
embedder cuts for text-embedding-3-small, and embedding batches are 64.
The heading-boundary cut and start-of-chunk labelling stay: they are the
two places this chunker is deliberately better than the one it was
ported from.

The document node records the chunk sizes it was cut with, and a
document is unchanged only when they match too. Before this a changed
--min-chars or --max-chars over an unchanged folder did nothing.
A name the ontology has no class for is kept flagged for review, not
trusted: fed back into the scan, "notes" tagged eighty-eight spans of
one corpus. The target ledger's doc:Entity nodes still join the
gazetteer, minus those marked doc:offModel.
`fluree doc search --mode hybrid` runs both indexes, each for three times
the requested count, and fuses them. The two methods do not score alike:
a cosine sits in 0..1 and 0.9 is very high, while a BM25 weight is an
unbounded sum where 3 is weak and 35 is strong, so fusing by rank alone
would credit a weak first place as much as a strong one. Each score is
put on one confidence scale first, cosine as it is and BM25 as
s / (s + 10), and a chunk's fused score is the mean over both methods
with a missing method counting 0. Agreement wins; a strong single-method
hit still outranks a weak agreed one. Each hit prints the raw score and
rank from every method that returned it.

`auto` now picks hybrid when both indexes exist and the query can be
embedded, else whichever index there is. On the Fed statements the query
"paramount lien on Reserve Bank assets" ranks the answering chunk tenth
by vector and first by text; fused, it leads at 0.53 against 0.28.

JSON output is now one object per hit (score, chunk, document, file,
path, text, ranks) instead of a positional array.
…n the account

Hybrid search ran the vector lane, embedding included, and then the BM25
lane, so it cost their sum. The two are independent and now run under
try_join, so hybrid costs the slower lane: on the Fed ledger 4.3 s
against 3.9 s for vector alone and 2.2 s for text.

Every search also began by resolving the account remote, which meant an
authenticated round trip to refresh the login before a BM25 query that
needs no token. Whether the query could be embedded is now decided from
the local config alone; the account is asked for its token only when a
vector lane runs, and only when the stored login is within a minute of
expiring, read from the JWT's exp without a server call.
A CLI process never runs the background indexer a server would, so a
ledger written by `doc ingest` stayed at index t=0 and every later
invocation replayed its commits into novelty before answering. On the
Fed statements, six commits carrying 209 vectors of 1536 floats, that was
824 ms for a trivial query and 2.1 s for a BM25 search; indexed, 6 ms
and 37 ms.

`fluree index`'s build-and-publish is factored into `index_ledger` and
ingest calls it after the documents, before the vector and full-text
syncs. `--no-index` now skips all three.
@bplatz bplatz added enhancement New feature or request area:cli fluree CLI UX, rdf toolkit, publish/export/insert flows labels Sep 3, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 3, 2026 02:35

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bplatz this makes sense and I have no disagreement with any of the fixes, their design, or their implementation (particularly given how they build on #1760). Claude review below:


This is careful work: the part I care about most in an extraction pipeline is who gets to decide identity and predicates, and here it's never the model: the gazetteer's IRI wins over anything minted, a minted IRI is a hash of the normalized name (so it can't carry hostile characters and one name is one node across runs), an entity whose excerpt isn't in the chunk is dropped with its relations, off-model types are flagged rather than adopted (and no longer seed the next run's gazetteer), and every predicate goes through the same exact-one Valid/Repaired/Rejected gate solo's extractor uses, with rejected relations kept only as reified evidence. Hybrid search fusing calibrated scores rather than ranks is the right call and the reasoning in the description is convincing. The four "found by running real documents" fixes are each real — the instructions drop in particular matches what solo-proxy#285 fixes on the proxy side, and your client-side choice works with both.

What I verified locally, since nothing ran in CI for this head: cargo fmt --check clean, cargo test -p fluree-db-doc 51/51, and the docs table's flags and defaults match the clap definitions. What I could not run: clippy --all-targets on fluree-db-cli with default features and the CLI integration tests, both because the CLI's new default vector feature (from #1760) needs a C++ toolchain for usearch/cxx that this machine doesn't have — which is the practical reason to retarget to main after #1760 merges, so the Linux lane runs them before this lands. One thing I'd fold in now: delete_triples_update interpolates operator-supplied gazetteer/model IRIs into DELETE DATA without the sparql_iri_safe-style check solo's publisher uses; the document IRI path is already percent-encoded, so it's a one-liner to match. And a sentence in the concept page that direct edges are claims-with-evidence would set operators' expectations right.

Adherence to repo commitments:

  • Patterns/abstractions: ✔ extends fluree-db-doc and the doc command; sources load through the database itself (scratch in-memory ledger) rather than a parallel loader; same gate semantics as solo's extractor.
  • Performance (speed first, memory second): ✔ no engine hot path; ingest now indexes the ledger (824 ms → 6 ms on the body's measurement); extraction bounded by concurrency, token and crop caps.
  • Testing: ✔ 51 unit tests green locally; CLI integration tests against a stub model server (+334) exist and are wired; ⚠️ not executed here and not by CI on this head — retarget so they run.
  • Conventions: ✔ thorough multi-line commits; docs updated across the CLI reference, ingest/search, concepts, configuration, vocabulary; fmt --check clean; ⚠️ stacked-PR CI gap.

Verified locally at branch HEAD 3ae651a33: cargo fmt -p fluree-db-doc -p fluree-db-cli -- --check → clean; cargo test -p fluree-db-doc → 51 passed; cargo clippy -p fluree-db-doc --all-targets -- -D warnings → clean; cargo clippy -p fluree-db-cli --no-default-features --features server,iceberg,shacl,aws → does not compile (vector API used with no cfg gate — inherited from #1760, filed there); extract.rs::resolve, minted_iri, document_iri, both SPARQL builders and their call sites read; docs-vs-clap spot-check on --relations, --concurrency, --mode.

Approving so you can merge once it's re-based and CI has seen it — and let's get the IRI gate in while it's one line.

pub fn delete_triples_update(triples: &[(String, String, String)]) -> String {
let body: Vec<String> = triples
.iter()
.map(|(s, p, o)| format!("<{s}> <{p}> <{o}> ."))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (fold in now). delete_triples_update interpolates every subject/predicate/object into <…> with format!. The triples come back from asserted_triples_query (doc.rs:471), so subjects and objects are minted hex IRIs or gazetteer IRIs and predicates are model property IRIs after the gate.

Minted ones are safe by construction; gazetteer and model IRIs are operator-supplied (--entities file, --model file), so a malformed IRI carrying > breaks out of the brackets and splices extra DELETE DATA triples on the target ledger. Low likelihood — the operator owns those files — but solo's publisher gates exactly this with a one-line sparql_iri_safe predicate (publish.rs:630 on solo#1118), and the same check here (refuse or percent-encode anything outside [^<>"{}|^\x00-\x20]) closes it. retract_update (:134) is already safe because document_iri` percent-encodes.

Minor and non-blocking — if you agree it's right, I'd rather see it here than in the backlog.

};
let names =
std::iter::once(name).chain(e.alternate_names.iter().flatten().map(String::as_str));
// The gazetteer's IRI wins over anything minted, and over the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise, and the line to protect. "The gazetteer's IRI wins over anything minted" plus the zero-occurrence drop at :553 and the exact-one judge at :573 is what keeps a document from inventing identities or predicates; a rejected relation surviving only as a reified node with its verdict (:589) is the right evidence-not-edge posture. If the repair rules ever grow a "closest match", that's where the model starts writing the graph.

@@ -0,0 +1,89 @@
# Entities and relations

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional (docs). Worth one plain sentence for operators: an untrusted document can attach admitted-predicate edges to any gazetteer entity it names by label — that is the feature — so a doc: ledger's direct edges are claims with evidence (the reified node carries excerpt and verdict), and downstream consumers should read them that way.

Comment thread fluree-db-doc/src/llm.rs
// former and drops the latter, and OpenAI accepts both.
let mut input = Vec::new();
if let Some(system) = req.system {
input.push(serde_json::json!({ "role": "system", "content": system }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note (cross-repo). Sending the system prompt as a system-role message because the Fluree AI gateway's proxy path drops instructions is the right client-side move, and it keeps working after solo-proxy#285's fix(responses) (which makes the proxy honor instructions) lands — both generations of client are then served. Worth cross-linking the two in the description so nobody "simplifies" the client back once the proxy is fixed.

Base automatically changed from feature/doc-ingest to main September 3, 2026 22:41
#1760 landed on main, so the vector lane's HNSW index is gone. Conflicts
resolved by keeping this branch's structure — two concurrent lanes, hybrid
fusion, the post-ingest ledger index — and swapping what the vector lane
runs.

The lane now scores every chunk's `doc:embedding` with `cosineSimilarity`,
ordered and cut by LIMIT, instead of searching a graph source. Fusion is
untouched: it calibrates against a cosine in 0..1, which is exactly what
flatrank produces, so the same scores arrive on the same scale.

`search_hits` takes the where-clauses that bind `?c` and `?score` plus an
optional query vector, rather than a single index pattern, so both lanes
build their own head and share the chunk join. Mode selection asks whether
the chunks carry embeddings (`has_embeddings`) rather than whether a vector
graph source exists, and the "no vector index" error becomes one about
embeddings, since that is now the real precondition.
`delete_triples_update` interpolated every subject, predicate and object
into `<…>` with `format!`. Minted IRIs are hex and document IRIs are
percent-encoded, but gazetteer and model IRIs come from operator-supplied
files (`--entities`, `--model`), and one carrying `>` closed the brackets
and spliced extra triples into a `DELETE DATA` on the target ledger.

`sparql_iri_safe` applies the RFC 3987 IRIREF exclusions. An unsafe triple
is skipped rather than escaped: it is stored under that exact IRI, so a
rewritten one would retract nothing and hide that it happened. The caller
filters first and reports what it left in place; the builder filters again
so it cannot be misused, and returns `None` when nothing is left.

The test asserts the injected IRI produces no update and that a safe
triple alongside it still retracts exactly one. Verified non-vacuous by
removing the filter: the update then reads
`DELETE DATA { <https://ex.org/x> <https://ex.org/p> <https://ex.org/o> .
<urn:victim> <https://ex.org/knows> <urn:fluree:doc:b> . }`.

Also documents that a `doc:` ledger's direct edges are claims with
evidence, not assertions: the gate decides which predicates may be
written, never which statements are true, so any ingested document can
attach an admitted-predicate edge to a gazetteer entity it names. The
reified node beside every edge carries excerpt, verdict and source, and
`--relations reified` writes the evidence without the edges.
The gateway's proxy path drops Responses `instructions`, so the prompt is
sent as a system-role message instead. That stays correct once the proxy
honors `instructions` — both shapes are then served — while reverting it
breaks any client pointed at a proxy that has not been updated, and the
failure is a prompt-less call that still returns 200 with null subjects
and objects in every relation.
@bplatz

bplatz commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all three folded in.

IRI gate. sparql_iri_safe applies the RFC 3987 IRIREF exclusions before anything reaches DELETE DATA. One departure from your suggestion: an unsafe triple is skipped rather than percent-encoded. These IRIs come back from querying what is already asserted, so a rewritten one matches nothing — it would retract nothing while reporting success. The caller filters and reports what it left in place; the builder filters again so it cannot be misused. Worth noting the exposure was a bit sharper than "low likelihood" reads: with the filter removed the test shows the update becoming DELETE DATA { <https://ex.org/x> <https://ex.org/p> <https://ex.org/o> . <urn:victim> … } — one malformed gazetteer IRI, two triples deleted.

Claims-with-evidence. New section on the concept page, in the terms you put it: the gate decides which predicates may be written, never which statements are true, so any ingested document can attach an admitted-predicate edge to a gazetteer entity it names by label. Every edge has a reified node beside it with excerpt, verdict and source, and --relations reified writes the evidence without the edges for a corpus you do not trust.

System-role message. The "do not simplify this back" note now lives as a comment in llm.rs where someone would be standing when tempted, rather than only in a description: it keeps working once the gateway honors instructions, and reverting it breaks any client on a proxy that has not been updated — with a prompt-less call that still returns 200.

#1760 merged, so this retargeted to main and CI has run on this head for the first time, which closes the stacked-PR gap you flagged. After merging main: fluree-db-doc 53/53, the full fluree-db-cli suite 357/357, fmt and clippy clean.

The vector lane also changed with #1760 — it is flatrank now, not an HNSW search. Fusion is untouched, since it calibrates against a cosine in 0..1 and that is exactly what flatrank produces.

One gap I would rather name than leave implied: hybrid has unit coverage for fuse_hits and an integration test for the missing-embedding error, but nothing exercises a fused ranking end to end. Predates this branch, though the vector lane underneath is now different code.

@bplatz
bplatz merged commit 93869ae into main Sep 3, 2026
17 checks passed
@bplatz
bplatz deleted the feature/doc-extract branch September 3, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli fluree CLI UX, rdf toolkit, publish/export/insert flows enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants