项目文件夹

文件
2026-07-13 12:08:54 +08:00

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