This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git
The following commit(s) were added to refs/heads/main by this push:
new 876d67ba feat(llm): make graph extraction split configurable (#359)
876d67ba is described below
commit 876d67ba2ef56248cfc020b51cc45916b0c4d582
Author: Nannan Wang <[email protected]>
AuthorDate: Mon Jun 8 12:35:50 2026 +0800
feat(llm): make graph extraction split configurable (#359)
Closes #343.
This PR makes the graph extraction split type configurable instead of
always forcing `document`.
---------
Co-authored-by: nannan-2026 <[email protected]>
---
.../config/models/base_prompt_config.py | 2 +
.../demo/rag_demo/vector_graph_block.py | 44 ++--
.../src/hugegraph_llm/flows/graph_extract.py | 33 ++-
.../operators/document_op/chunk_split.py | 15 +-
.../src/hugegraph_llm/utils/graph_index_utils.py | 24 ++-
.../test_graph_extract_configurable_split.py | 238 +++++++++++++++++++++
6 files changed, 335 insertions(+), 21 deletions(-)
diff --git
a/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py
b/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py
index f1e0c6c1..e8eb663f 100644
--- a/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py
+++ b/hugegraph-llm/src/hugegraph_llm/config/models/base_prompt_config.py
@@ -78,6 +78,7 @@ class BasePromptConfig:
text2gql_graph_schema: str = ""
gremlin_generate_prompt: str = ""
doc_input_text: str = ""
+ graph_extract_split_type: str = "document"
_language_generated: str = ""
generate_extract_prompt_template: str = ""
@@ -136,6 +137,7 @@ class BasePromptConfig:
"keywords_extract_prompt":
to_literal(self.keywords_extract_prompt),
"gremlin_generate_prompt":
to_literal(self.gremlin_generate_prompt),
"doc_input_text": to_literal(self.doc_input_text),
+ "graph_extract_split_type":
to_literal(self.graph_extract_split_type),
"_language_generated":
str(self.llm_settings.language).lower().strip(),
"generate_extract_prompt_template":
to_literal(self.generate_extract_prompt_template),
}
diff --git
a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/vector_graph_block.py
b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/vector_graph_block.py
index 6816d9f4..81de3480 100644
--- a/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/vector_graph_block.py
+++ b/hugegraph-llm/src/hugegraph_llm/demo/rag_demo/vector_graph_block.py
@@ -44,12 +44,17 @@ from hugegraph_llm.utils.vector_index_utils import (
)
-def store_prompt(doc, schema, example_prompt):
- # update env variables: doc, schema and example_prompt
- if prompt.doc_input_text != doc or prompt.graph_schema != schema or
prompt.extract_graph_prompt != example_prompt:
+def store_prompt(doc, schema, example_prompt,
graph_extract_split_type="document"):
+ if (
+ prompt.doc_input_text != doc
+ or prompt.graph_schema != schema
+ or prompt.extract_graph_prompt != example_prompt
+ or prompt.graph_extract_split_type != graph_extract_split_type
+ ):
prompt.doc_input_text = doc
prompt.graph_schema = schema
prompt.extract_graph_prompt = example_prompt
+ prompt.graph_extract_split_type = graph_extract_split_type
prompt.update_yaml_file()
@@ -270,6 +275,12 @@ def create_vector_graph_block():
graph_data_btn0 = gr.Button("Clear Graph Data", size="sm")
vector_import_bt = gr.Button("Import into Vector",
variant="primary")
+ graph_split_type = gr.Dropdown(
+ choices=["document", "paragraph", "sentence"],
+ value=prompt.graph_extract_split_type,
+ label="Graph Extraction Split Type",
+ info=("document keeps the current behavior; paragraph/sentence
split long docs before extraction."),
+ )
graph_extract_bt = gr.Button("Extract Graph Data (1)",
variant="primary")
graph_loading_bt = gr.Button("Load into GraphDB (2)",
interactive=True)
graph_index_rebuild_bt = gr.Button("Update Vid Embedding")
@@ -300,48 +311,54 @@ def create_vector_graph_block():
vector_index_btn0.click(get_vector_index_info, outputs=out).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
vector_index_btn1.click(clean_vector_index).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
vector_import_bt.click(build_vector_index, inputs=[input_file,
input_text], outputs=out).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
graph_index_btn0.click(get_graph_index_info, outputs=out).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
graph_index_btn1.click(clean_all_graph_index).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
graph_data_btn0.click(clean_all_graph_data).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
graph_index_rebuild_bt.click(update_vid_embedding, outputs=out).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
# origin_out = gr.Textbox(visible=False)
graph_extract_bt.click(
extract_graph,
- inputs=[input_file, input_text, input_schema,
info_extract_template],
+ inputs=[
+ input_file,
+ input_text,
+ input_schema,
+ info_extract_template,
+ graph_split_type,
+ ],
outputs=[out],
).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
graph_loading_bt.click(import_graph_data, inputs=[out, input_schema],
outputs=[out]).then(
update_vid_embedding
).then(
store_prompt,
- inputs=[input_text, input_schema, info_extract_template],
+ inputs=[input_text, input_schema, info_extract_template,
graph_split_type],
)
# TODO: we should store the examples after the user changed them.
@@ -355,6 +372,7 @@ def create_vector_graph_block():
input_text,
input_schema,
info_extract_template,
+ graph_split_type,
], # TODO: Store the updated examples
)
diff --git a/hugegraph-llm/src/hugegraph_llm/flows/graph_extract.py
b/hugegraph-llm/src/hugegraph_llm/flows/graph_extract.py
index 0057f2b7..13629e2b 100644
--- a/hugegraph-llm/src/hugegraph_llm/flows/graph_extract.py
+++ b/hugegraph-llm/src/hugegraph_llm/flows/graph_extract.py
@@ -21,6 +21,10 @@ from hugegraph_llm.flows.common import BaseFlow
from hugegraph_llm.nodes.document_node.chunk_split import ChunkSplitNode
from hugegraph_llm.nodes.hugegraph_node.schema import SchemaNode
from hugegraph_llm.nodes.llm_node.extract_info import ExtractNode
+from hugegraph_llm.operators.document_op.chunk_split import (
+ SPLIT_TYPE_DOCUMENT,
+ VALID_SPLIT_TYPES,
+)
from hugegraph_llm.state.ai_state import WkFlowInput, WkFlowState
from hugegraph_llm.utils.log import log
@@ -37,22 +41,43 @@ class GraphExtractFlow(BaseFlow):
texts,
example_prompt,
extract_type,
+ split_type=SPLIT_TYPE_DOCUMENT,
language="zh",
**kwargs,
):
# prepare input data
prepared_input.texts = texts
prepared_input.language = language
- prepared_input.split_type = "document"
+ if split_type not in VALID_SPLIT_TYPES:
+ raise ValueError("split_type must be document, paragraph, or
sentence")
+
+ prepared_input.split_type = split_type
prepared_input.example_prompt = example_prompt
prepared_input.schema = schema
prepared_input.extract_type = extract_type
- def build_flow(self, schema, texts, example_prompt, extract_type,
language="zh", **kwargs):
+ def build_flow(
+ self,
+ schema,
+ texts,
+ example_prompt,
+ extract_type,
+ split_type=SPLIT_TYPE_DOCUMENT,
+ language="zh",
+ **kwargs,
+ ):
pipeline = GPipeline()
prepared_input = WkFlowInput()
# prepare input data
- self.prepare(prepared_input, schema, texts, example_prompt,
extract_type, language)
+ self.prepare(
+ prepared_input,
+ schema,
+ texts,
+ example_prompt,
+ extract_type,
+ split_type,
+ language,
+ )
pipeline.createGParam(prepared_input, "wkflow_input")
pipeline.createGParam(WkFlowState(), "wkflow_state")
@@ -70,6 +95,8 @@ class GraphExtractFlow(BaseFlow):
res = pipeline.getGParamWithNoEmpty("wkflow_state").to_json()
vertices = res.get("vertices", [])
edges = res.get("edges", [])
+ chunk_count = len(res.get("chunks", []))
+ log.info("Graph extraction chunk_count: %s", chunk_count)
if not vertices and not edges:
log.info("Please check the schema.(The schema may not match the
Doc)")
return json.dumps(
diff --git
a/hugegraph-llm/src/hugegraph_llm/operators/document_op/chunk_split.py
b/hugegraph-llm/src/hugegraph_llm/operators/document_op/chunk_split.py
index a22e4de8..83a1d4bb 100644
--- a/hugegraph-llm/src/hugegraph_llm/operators/document_op/chunk_split.py
+++ b/hugegraph-llm/src/hugegraph_llm/operators/document_op/chunk_split.py
@@ -16,6 +16,7 @@
# under the License.
+import re
from typing import Any, Dict, List, Literal, Optional, Union
from langchain_text_splitters import RecursiveCharacterTextSplitter
@@ -26,6 +27,16 @@ LANGUAGE_EN = "en"
SPLIT_TYPE_DOCUMENT = "document"
SPLIT_TYPE_PARAGRAPH = "paragraph"
SPLIT_TYPE_SENTENCE = "sentence"
+VALID_SPLIT_TYPES = (
+ SPLIT_TYPE_DOCUMENT,
+ SPLIT_TYPE_PARAGRAPH,
+ SPLIT_TYPE_SENTENCE,
+)
+
+
+def _split_sentence_boundaries(text: str) -> list[str]:
+ sentence_pattern =
re.compile(r"[^.!?\u3002\uff01\uff1f\uff1b;]+[.!?\u3002\uff01\uff1f\uff1b;]*")
+ return [sentence.strip() for sentence in sentence_pattern.findall(text) if
sentence.strip()]
class ChunkSplit:
@@ -56,8 +67,8 @@ class ChunkSplit:
chunk_size=500, chunk_overlap=30, separators=self.separators
).split_text
if split_type == SPLIT_TYPE_SENTENCE:
- return RecursiveCharacterTextSplitter(chunk_size=50,
chunk_overlap=0, separators=self.separators).split_text
- raise ValueError("Type must be paragraph, sentence, html or markdown")
+ return _split_sentence_boundaries
+ raise ValueError("split_type must be document, paragraph, or sentence")
def run(self, context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
all_chunks = []
diff --git a/hugegraph-llm/src/hugegraph_llm/utils/graph_index_utils.py
b/hugegraph-llm/src/hugegraph_llm/utils/graph_index_utils.py
index 423526ea..78e9030d 100644
--- a/hugegraph-llm/src/hugegraph_llm/utils/graph_index_utils.py
+++ b/hugegraph-llm/src/hugegraph_llm/utils/graph_index_utils.py
@@ -24,6 +24,10 @@ from pyhugegraph.client import PyHugeClient
from hugegraph_llm.flows import FlowName
from hugegraph_llm.flows.scheduler import SchedulerSingleton
+from hugegraph_llm.operators.document_op.chunk_split import (
+ SPLIT_TYPE_DOCUMENT,
+ VALID_SPLIT_TYPES,
+)
from ..config import huge_settings
from .hugegraph_utils import clean_hg_data
@@ -77,14 +81,28 @@ def clean_all_graph_data():
gr.Info("Clear graph data successfully!")
-def extract_graph(input_file, input_text, schema, example_prompt) -> str:
+def extract_graph(
+ input_file,
+ input_text,
+ schema,
+ example_prompt,
+ split_type=SPLIT_TYPE_DOCUMENT,
+) -> str:
texts = read_documents(input_file, input_text)
scheduler = SchedulerSingleton.get_instance()
if not schema:
return "ERROR: please input with correct schema/format."
-
+ if split_type not in VALID_SPLIT_TYPES:
+ raise gr.Error("split_type must be document, paragraph, or sentence")
try:
- return scheduler.schedule_flow(FlowName.GRAPH_EXTRACT, schema, texts,
example_prompt, "property_graph")
+ return scheduler.schedule_flow(
+ FlowName.GRAPH_EXTRACT,
+ schema,
+ texts,
+ example_prompt,
+ "property_graph",
+ split_type=split_type,
+ )
except Exception as e: # pylint: disable=broad-exception-caught
log.error(e)
raise gr.Error(str(e))
diff --git
a/hugegraph-llm/src/tests/document/test_graph_extract_configurable_split.py
b/hugegraph-llm/src/tests/document/test_graph_extract_configurable_split.py
new file mode 100644
index 00000000..4e5078bb
--- /dev/null
+++ b/hugegraph-llm/src/tests/document/test_graph_extract_configurable_split.py
@@ -0,0 +1,238 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+from types import SimpleNamespace
+
+import gradio as gr
+import pytest
+
+from hugegraph_llm.config.models import base_prompt_config
+from hugegraph_llm.config.models.base_prompt_config import BasePromptConfig
+from hugegraph_llm.flows import FlowName
+from hugegraph_llm.flows.graph_extract import GraphExtractFlow
+from hugegraph_llm.operators.document_op.chunk_split import ChunkSplit
+from hugegraph_llm.state.ai_state import WkFlowInput
+from hugegraph_llm.utils import graph_index_utils
+
+
+class DummyScheduler:
+ def __init__(self):
+ self.calls = []
+ self.kwargs = []
+
+ def schedule_flow(self, *args, **kwargs):
+ self.calls.append(args)
+ self.kwargs.append(kwargs)
+ return "scheduled"
+
+
+class DummyPipelineState:
+ def to_json(self):
+ return {
+ "chunks": ["chunk one", "chunk two"],
+ "vertices": [{"id": "person:alice"}],
+ "edges": [],
+ }
+
+
+class DummyPipeline:
+ def getGParamWithNoEmpty(self, name):
+ assert name == "wkflow_state"
+ return DummyPipelineState()
+
+
+class CapturePipeline:
+ def __init__(self):
+ self.params = {}
+
+ def createGParam(self, value, name):
+ self.params[name] = value
+
+ def registerGElement(self, *args):
+ return None
+
+
+def test_graph_extract_prepare_preserves_default_document_split_type():
+ prepared_input = WkFlowInput()
+
+ GraphExtractFlow().prepare(
+ prepared_input,
+ "{}",
+ ["first document"],
+ "extract prompt",
+ "property_graph",
+ )
+
+ assert prepared_input.split_type == "document"
+
+
+def test_graph_extract_prepare_accepts_non_default_split_type():
+ prepared_input = WkFlowInput()
+
+ GraphExtractFlow().prepare(
+ prepared_input,
+ "{}",
+ ["first paragraph\n\nsecond paragraph"],
+ "extract prompt",
+ "property_graph",
+ "paragraph",
+ )
+
+ assert prepared_input.split_type == "paragraph"
+
+
+def test_graph_extract_prepare_rejects_invalid_split_type():
+ prepared_input = WkFlowInput()
+
+ with pytest.raises(ValueError, match="split_type must be document"):
+ GraphExtractFlow().prepare(
+ prepared_input,
+ "{}",
+ ["first document"],
+ "extract prompt",
+ "property_graph",
+ "invalid",
+ )
+
+
+def
test_graph_extract_build_flow_passes_non_default_split_type_to_workflow_input(
+ monkeypatch,
+):
+ monkeypatch.setattr(
+ "hugegraph_llm.flows.graph_extract.GPipeline",
+ CapturePipeline,
+ )
+
+ pipeline = GraphExtractFlow().build_flow(
+ "{}",
+ ["first paragraph\n\nsecond paragraph"],
+ "extract prompt",
+ "property_graph",
+ "paragraph",
+ )
+
+ assert pipeline.params["wkflow_input"].split_type == "paragraph"
+
+
+def test_chunk_split_non_default_types_produce_multiple_chunks():
+ paragraph_text = ("Alpha " * 120) + "\n\n" + ("Beta " * 120)
+ sentence_text = "Alpha sentence. Beta sentence. Gamma sentence. Delta
sentence. Epsilon sentence. Zeta sentence."
+
+ paragraph_chunks = ChunkSplit(paragraph_text, "paragraph",
"en").run(None)["chunks"]
+ sentence_chunks = ChunkSplit(sentence_text, "sentence",
"en").run(None)["chunks"]
+
+ assert len(paragraph_chunks) > 1
+ assert len(sentence_chunks) > 1
+
+
+def test_extract_graph_helper_forwards_selected_split_type(monkeypatch):
+ scheduler = DummyScheduler()
+ monkeypatch.setattr(
+ graph_index_utils,
+ "read_documents",
+ lambda input_file, input_text: ["graph extraction text"],
+ )
+ monkeypatch.setattr(
+ graph_index_utils.SchedulerSingleton,
+ "get_instance",
+ lambda: scheduler,
+ )
+
+ result = graph_index_utils.extract_graph(
+ [],
+ "",
+ "{}",
+ "extract prompt",
+ "sentence",
+ )
+
+ assert result == "scheduled"
+ assert scheduler.calls == [
+ (
+ FlowName.GRAPH_EXTRACT,
+ "{}",
+ ["graph extraction text"],
+ "extract prompt",
+ "property_graph",
+ )
+ ]
+ assert scheduler.kwargs == [{"split_type": "sentence"}]
+
+
+def test_extract_graph_helper_rejects_invalid_split_type(monkeypatch):
+ monkeypatch.setattr(
+ graph_index_utils,
+ "read_documents",
+ lambda input_file, input_text: ["graph extraction text"],
+ )
+ monkeypatch.setattr(
+ graph_index_utils.SchedulerSingleton,
+ "get_instance",
+ lambda: DummyScheduler(),
+ )
+
+ with pytest.raises(gr.Error, match="split_type must be document"):
+ graph_index_utils.extract_graph(
+ [],
+ "",
+ "{}",
+ "extract prompt",
+ "invalid",
+ )
+
+
+def test_graph_extract_post_deal_logs_chunk_count(monkeypatch):
+ log_calls = []
+ monkeypatch.setattr(
+ "hugegraph_llm.flows.graph_extract.log.info",
+ lambda message, *args: log_calls.append((message, args)),
+ )
+
+ result = GraphExtractFlow().post_deal(DummyPipeline())
+ result_data = json.loads(result)
+
+ assert result_data["vertices"] == [{"id": "person:alice"}]
+ assert any(message == "Graph extraction chunk_count: %s" and args == (2,)
for message, args in log_calls)
+
+
+def test_sentence_split_returns_punctuation_delimited_sentences():
+ chunks = ChunkSplit(
+ "Alpha sentence one. Beta sentence two? Gamma sentence three!",
+ "sentence",
+ "en",
+ ).run(None)["chunks"]
+
+ assert chunks == [
+ "Alpha sentence one.",
+ "Beta sentence two?",
+ "Gamma sentence three!",
+ ]
+
+
+def test_prompt_config_round_trips_graph_extract_split_type(monkeypatch,
tmp_path):
+ prompt_path = tmp_path / "config_prompt.yaml"
+ monkeypatch.setattr(base_prompt_config, "yaml_file_path", str(prompt_path))
+
+ config = BasePromptConfig()
+ config.llm_settings = SimpleNamespace(language="en")
+ config.graph_extract_split_type = "sentence"
+ config.save_to_yaml()
+
+ reloaded = BasePromptConfig()
+ reloaded.llm_settings = SimpleNamespace(language="en")
+ reloaded.ensure_yaml_file_exists()
+
+ assert reloaded.graph_extract_split_type == "sentence"