hkuds--lightrag
350 行
14 KiB
Python
350 行
14 KiB
Python
"""Semantic vector chunking — the ``"V"`` strategy.
|
|
|
|
Wraps LangChain's :class:`SemanticChunker` (from ``langchain-experimental``)
|
|
which splits text by sentence embeddings: it first segments the input into
|
|
sentences, embeds each sentence (in adjacent windows of ``buffer_size``),
|
|
and finds breakpoints where the cosine distance between consecutive
|
|
windows crosses a threshold derived from the chosen distribution
|
|
(``percentile`` / ``standard_deviation`` / ``interquartile`` /
|
|
``gradient``).
|
|
|
|
The chunker exposed here is ``async`` because LightRAG's
|
|
:class:`EmbeddingFunc` is async. Internally we call SemanticChunker
|
|
synchronously inside :func:`asyncio.to_thread` and bridge the embedding
|
|
calls back to the main event loop via
|
|
:func:`asyncio.run_coroutine_threadsafe`.
|
|
|
|
Caveats:
|
|
- SemanticChunker does NOT enforce a maximum chunk size; the caller's
|
|
``chunk_token_size`` is *advisory* here. Oversized chunks will be
|
|
hard-split before embedding by
|
|
:func:`lightrag.utils.enforce_chunk_token_limit_before_embedding`.
|
|
- When ``embedding_func`` is ``None`` we log a warning and fall back to
|
|
:func:`lightrag.chunker.chunking_by_recursive_character` — V's only
|
|
differentiator is embeddings, and R is the closest structural-only
|
|
alternative.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from lightrag.constants import DEFAULT_SENTENCE_SPLIT_REGEX
|
|
from lightrag.utils import EmbeddingFunc, Tokenizer, logger
|
|
|
|
if TYPE_CHECKING:
|
|
from langchain_experimental.text_splitter import (
|
|
SemanticChunker as SemanticChunkerType,
|
|
)
|
|
else:
|
|
SemanticChunkerType = Any
|
|
|
|
try:
|
|
from langchain_core.embeddings import Embeddings
|
|
from langchain_experimental.text_splitter import SemanticChunker
|
|
|
|
_LANGCHAIN_EXPERIMENTAL_AVAILABLE = True
|
|
except ImportError:
|
|
_LANGCHAIN_EXPERIMENTAL_AVAILABLE = False
|
|
Embeddings = object # type: ignore[assignment,misc]
|
|
SemanticChunker = None # type: ignore[assignment]
|
|
|
|
|
|
class _AsyncEmbeddingFuncAdapter(Embeddings):
|
|
"""Bridge a LightRAG :class:`EmbeddingFunc` (async) to LangChain's
|
|
sync :class:`Embeddings` interface used by ``SemanticChunker``.
|
|
|
|
The adapter must be constructed inside the running event loop so it
|
|
can capture the loop reference; the blocking ``embed_documents`` /
|
|
``embed_query`` calls are then made from a worker thread (via
|
|
:func:`asyncio.to_thread` in the public chunker) and bounce back to
|
|
the captured loop with :func:`asyncio.run_coroutine_threadsafe`.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
embedding_func: EmbeddingFunc,
|
|
loop: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
self._embedding_func = embedding_func
|
|
self._loop = loop
|
|
|
|
def _run(self, texts: list[str], context: str) -> list[list[float]]:
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
self._embedding_func(texts, context=context),
|
|
self._loop,
|
|
)
|
|
result = future.result()
|
|
return [list(map(float, vec)) for vec in result]
|
|
|
|
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
return self._run(list(texts), context="document")
|
|
|
|
def embed_query(self, text: str) -> list[float]:
|
|
return self._run([text], context="query")[0]
|
|
|
|
|
|
def _sentence_spans(text: str, sentences: list[str]) -> list[tuple[int, int]]:
|
|
spans: list[tuple[int, int]] = []
|
|
cursor = 0
|
|
for sentence in sentences:
|
|
if not sentence:
|
|
spans.append((cursor, cursor))
|
|
continue
|
|
start = text.find(sentence, cursor)
|
|
if start < 0:
|
|
start = text.find(sentence)
|
|
if start < 0:
|
|
start = cursor
|
|
end = start + len(sentence)
|
|
spans.append((start, end))
|
|
cursor = end
|
|
return spans
|
|
|
|
|
|
def _trim_span(text: str, start: int, end: int) -> tuple[int, int]:
|
|
start = max(0, min(start, len(text)))
|
|
end = max(start, min(end, len(text)))
|
|
while start < end and text[start].isspace():
|
|
start += 1
|
|
while end > start and text[end - 1].isspace():
|
|
end -= 1
|
|
return start, end
|
|
|
|
|
|
def _semantic_groups_with_spans(
|
|
splitter: SemanticChunkerType,
|
|
text: str,
|
|
) -> list[tuple[str, int, int]]:
|
|
"""Mirror SemanticChunker grouping while keeping original source spans.
|
|
|
|
.. warning::
|
|
This re-implements the body of ``SemanticChunker.split_text`` so each group
|
|
carries its exact source span (``text[start:end]``) instead of the upstream
|
|
``" ".join(sentences)`` reflow. It relies on **private** members
|
|
(``sentence_split_regex``, ``breakpoint_threshold_type``, ``min_chunk_size``,
|
|
``number_of_chunks``, ``_calculate_sentence_distances``,
|
|
``_threshold_from_clusters``, ``_calculate_breakpoint_threshold``). Verified
|
|
byte-for-byte against ``langchain-experimental`` 0.3.2–0.4.x (the range pinned
|
|
in ``pyproject.toml``: ``langchain-experimental>=0.3.2,<1``). If that pin is
|
|
widened, re-verify against the new upstream ``split_text`` —
|
|
``tests/chunker/test_chunker_semantic_vector.py`` has a drift guard that
|
|
compares this mirror's grouping to the live ``splitter.split_text`` output.
|
|
"""
|
|
single_sentences_list = re.split(splitter.sentence_split_regex, text)
|
|
spans = _sentence_spans(text, single_sentences_list)
|
|
|
|
def _group(start_index: int, end_index: int) -> tuple[str, int, int] | None:
|
|
start, _ = spans[start_index]
|
|
_, end = spans[end_index]
|
|
start, end = _trim_span(text, start, end)
|
|
if start >= end:
|
|
return None
|
|
return text[start:end], start, end
|
|
|
|
if len(single_sentences_list) == 1:
|
|
group = _group(0, 0)
|
|
return [group] if group else []
|
|
if (
|
|
splitter.breakpoint_threshold_type == "gradient"
|
|
and len(single_sentences_list) == 2
|
|
):
|
|
return [g for i in range(2) if (g := _group(i, i)) is not None]
|
|
|
|
distances, sentences = splitter._calculate_sentence_distances(single_sentences_list)
|
|
if splitter.number_of_chunks is not None:
|
|
breakpoint_distance_threshold = splitter._threshold_from_clusters(distances)
|
|
breakpoint_array = distances
|
|
else:
|
|
breakpoint_distance_threshold, breakpoint_array = (
|
|
splitter._calculate_breakpoint_threshold(distances)
|
|
)
|
|
|
|
indices_above_thresh = [
|
|
i for i, x in enumerate(breakpoint_array) if x > breakpoint_distance_threshold
|
|
]
|
|
|
|
chunks: list[tuple[str, int, int]] = []
|
|
start_index = 0
|
|
for index in indices_above_thresh:
|
|
end_index = index
|
|
group_sentences = sentences[start_index : end_index + 1]
|
|
combined_text = " ".join([d["sentence"] for d in group_sentences])
|
|
if (
|
|
splitter.min_chunk_size is not None
|
|
and len(combined_text) < splitter.min_chunk_size
|
|
):
|
|
continue
|
|
group = _group(start_index, end_index)
|
|
if group is not None:
|
|
chunks.append(group)
|
|
start_index = index + 1
|
|
|
|
if start_index < len(sentences):
|
|
group = _group(start_index, len(sentences) - 1)
|
|
if group is not None:
|
|
chunks.append(group)
|
|
return chunks
|
|
|
|
|
|
async def chunking_by_semantic_vector(
|
|
tokenizer: Tokenizer,
|
|
content: str,
|
|
chunk_token_size: int = 1200,
|
|
*,
|
|
embedding_func: EmbeddingFunc | None = None,
|
|
breakpoint_threshold_type: str = "percentile",
|
|
breakpoint_threshold_amount: float | None = None,
|
|
buffer_size: int = 1,
|
|
sentence_split_regex: str = DEFAULT_SENTENCE_SPLIT_REGEX,
|
|
number_of_chunks: int | None = None,
|
|
min_chunk_size: int | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Semantic vector chunker — the ``"V"`` chunking strategy.
|
|
|
|
Args:
|
|
tokenizer: LightRAG tokenizer (used for output token counts).
|
|
content: Text to split.
|
|
chunk_token_size: Hard upper bound (tokens). SemanticChunker does
|
|
NOT enforce a maximum natively, so any piece that exceeds
|
|
this value is re-split via
|
|
:func:`chunking_by_recursive_character` before being emitted.
|
|
embedding_func: LightRAG :class:`EmbeddingFunc`. When ``None``
|
|
this chunker logs a warning and falls back to
|
|
:func:`chunking_by_recursive_character`.
|
|
breakpoint_threshold_type: ``percentile`` | ``standard_deviation``
|
|
| ``interquartile`` | ``gradient`` (LangChain default:
|
|
``percentile``).
|
|
breakpoint_threshold_amount: Threshold magnitude. ``None`` lets
|
|
LangChain pick the per-type default (e.g. 95 for percentile).
|
|
buffer_size: Number of adjacent sentences combined when computing
|
|
distances (LangChain default: 1).
|
|
sentence_split_regex: Pattern fed to LangChain's
|
|
:class:`SemanticChunker` for the initial sentence split.
|
|
Default extends the upstream English-only pattern with
|
|
Chinese sentence terminators ``。?!`` so mixed-language and
|
|
pure-Chinese inputs split correctly.
|
|
number_of_chunks: Optional target chunk count (LangChain SemanticChunker).
|
|
min_chunk_size: Optional minimum character size for semantic groups.
|
|
|
|
Returns:
|
|
Ordered list of ``{"tokens", "content", "chunk_order_index"}``
|
|
dicts.
|
|
"""
|
|
if not content or not content.strip():
|
|
return []
|
|
|
|
if embedding_func is None:
|
|
# V's only differentiator is embeddings — without them the
|
|
# closest neighbour is R's structural splitting. V chunks are
|
|
# non-overlapping by design (semantic boundaries), so the
|
|
# fallback uses ``chunk_overlap_token_size=0`` to preserve that
|
|
# semantic and avoid LangChain's "overlap > chunk_size" guard
|
|
# for very small ``chunk_token_size``.
|
|
logger.warning(
|
|
"[semantic_vector] embedding_func is None; falling back to "
|
|
"recursive-character chunking."
|
|
)
|
|
from lightrag.chunker.recursive_character import (
|
|
chunking_by_recursive_character,
|
|
)
|
|
|
|
return chunking_by_recursive_character(
|
|
tokenizer,
|
|
content,
|
|
chunk_token_size,
|
|
chunk_overlap_token_size=0,
|
|
)
|
|
|
|
if not _LANGCHAIN_EXPERIMENTAL_AVAILABLE:
|
|
raise ImportError(
|
|
"langchain-experimental is required for the 'V' chunking "
|
|
"strategy; install with `pip install langchain-experimental>=0.3.2`."
|
|
)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
adapter = _AsyncEmbeddingFuncAdapter(embedding_func, loop)
|
|
|
|
chunker_kwargs: dict[str, Any] = {
|
|
"embeddings": adapter,
|
|
"buffer_size": int(buffer_size),
|
|
"breakpoint_threshold_type": breakpoint_threshold_type,
|
|
"sentence_split_regex": sentence_split_regex,
|
|
"number_of_chunks": number_of_chunks,
|
|
"min_chunk_size": min_chunk_size,
|
|
}
|
|
if breakpoint_threshold_amount is not None:
|
|
chunker_kwargs["breakpoint_threshold_amount"] = float(
|
|
breakpoint_threshold_amount
|
|
)
|
|
|
|
splitter = SemanticChunker(**chunker_kwargs)
|
|
pieces = await asyncio.to_thread(_semantic_groups_with_spans, splitter, content)
|
|
|
|
# SemanticChunker has no internal size cap; oversized pieces here
|
|
# would otherwise rely on the embedding-time hard fallback (which
|
|
# uses ``embedding_token_limit``, not ``chunk_token_size``) to split
|
|
# them. Enforce ``chunk_token_size`` directly via R for any piece
|
|
# that exceeds it so the user-configured size is actually honored.
|
|
# Lazy import dodges the recursive_character ↔ semantic_vector
|
|
# circular dependency (same pattern as the embedding-None fallback
|
|
# above).
|
|
from lightrag.chunker.recursive_character import (
|
|
chunking_by_recursive_character,
|
|
)
|
|
|
|
target_max = max(int(chunk_token_size), 1)
|
|
results: list[dict[str, Any]] = []
|
|
for piece, source_start, source_end in pieces:
|
|
body = piece.strip()
|
|
if not body:
|
|
continue
|
|
piece_tokens = len(tokenizer.encode(body))
|
|
if piece_tokens <= target_max:
|
|
results.append(
|
|
{
|
|
"tokens": piece_tokens,
|
|
"content": body,
|
|
"chunk_order_index": len(results),
|
|
"_source_span": {
|
|
"start": source_start,
|
|
"end": source_end,
|
|
},
|
|
}
|
|
)
|
|
continue
|
|
# Oversized semantic piece: re-split via R while preserving the
|
|
# surrounding chunk order. ``chunk_overlap_token_size=0`` keeps
|
|
# V's non-overlapping semantics.
|
|
sub_pieces = chunking_by_recursive_character(
|
|
tokenizer,
|
|
body,
|
|
target_max,
|
|
chunk_overlap_token_size=0,
|
|
)
|
|
for sub in sub_pieces:
|
|
sub_body = sub.get("content", "")
|
|
if not sub_body:
|
|
continue
|
|
sub_span = sub.get("_source_span")
|
|
source_span = None
|
|
if isinstance(sub_span, dict):
|
|
try:
|
|
source_span = {
|
|
"start": source_start + int(sub_span["start"]),
|
|
"end": source_start + int(sub_span["end"]),
|
|
}
|
|
except (KeyError, TypeError, ValueError):
|
|
source_span = None
|
|
results.append(
|
|
{
|
|
"tokens": sub.get("tokens", len(tokenizer.encode(sub_body))),
|
|
"content": sub_body,
|
|
"chunk_order_index": len(results),
|
|
**({"_source_span": source_span} if source_span else {}),
|
|
}
|
|
)
|
|
return results
|