示例代码(部分):
from llama_index.core.node_parser import SemanticSplitterNodeParser
from transformers import AutoTokenizer
def _setup_semantic_chunking(self, embedding_model_name):
if embedding_model_name:
self.embedding_model_name = embedding_model_name
self.embed_model = HuggingFaceEmbedding(
model_name=self.embedding_model_name,
trust_remote_code=True,
embed_batch_size=1,
)
self.splitter = SemanticSplitterNodeParser(
embed_model=self.embed_model,
show_progress=False,
)
# Get semantic nodes
nodes = [
(node.start_char_idx, node.end_char_idx)
for node in self.splitter.get_nodes_from_documents(
[Document(text=text)], show_progress=False
)
]
# Tokenize the entire text
tokens = tokenizer.encode_plus(
text,
return_offsets_mapping=True,
add_special_tokens=False,
padding=True,
truncation=True,
)
token_offsets = tokens.offset_mapping
两个变量 nodes
和 token_offsets
都和“文本的切分”有关,但它们处理粒度不同、使用目的不同、来源逻辑也不同。
✅ 1. nodes
是什么?
nodes
是 语义切割后的“文本片段的起止位置”,通常基于语义信息切分出来的段落、句子、子句。
nodes = [
(node.start_char_idx, node.end_char_idx)
for node in self.splitter.get_nodes_from_documents([Document(text=text)])
]
这个结果通常像:
[(0, 15), (16, 42), (43, 60)]
它的意思是:
- 第一个节点是从第 0 到 15 个字符(text[0:15])
- 第二个节点是从第 16 到 42 个字符(text[16:42])
- …
🔹核心点:
- 粒度是“句子”或“语义片段”
- 是基于语义模型(如 Sentence-BERT)判断哪里断句合理,不按标点
- 是用于做语义搜索、文档拆分等任务的输入块(比如做检索的 Chunk)
✅ 2. token_offsets
是什么?
这是 tokenizer 返回的 每个 token 在原始字符里的起止位置:
tokens = tokenizer.encode_plus(
text,
return_offsets_mapping=True,
)
token_offsets = tokens.offset_mapping
示例输出可能是:
[(0, 2), (2, 5), (6, 8), (8, 10), (10, 11)]
代表:
- 第一个 token 是 text[0:2]
- 第二个 token 是 text[2:5]
- …
🔹核心点:
- 粒度是“token”,就是 BERT 中的词片段(像 “天气很好” 会被分成 [“天气”, “很”, “好”])
- 是用于模型输入的 token 对齐
- 可以把 token 与原文字符一一对齐(做高亮、span mapping 等非常有用)
✅ 总结对比:
项目 | nodes | token_offsets |
---|---|---|
目标 | 语义片段起止位置(用于分段) | 每个 token 在原文中位置 |
粒度 | 段 / 句子 / 子句(高层语义) | token / 字符片段(细节) |
来源 | SemanticSplitter.get_nodes_from_documents() | tokenizer.encode_plus(..., return_offsets_mapping=True) |
应用场景 | 文档切块、索引、摘要、检索 | 映射 token → 原文、对齐字符、做高亮 |
✅ 举个例子
文本:
"今天天气很好,我想出去玩。"
-
nodes
返回:[(0, 8), (9, 16)] # 两个语义句子
-
token_offsets
返回:[(0, 2), (2, 4), (4, 6), (6, 8), (8, 9), (9, 11), (11, 13), (13, 16)] # 每个 token 对应字符位置
💡 nodes
与 token_offsets
的区别和联系:
项目 | nodes | token_offsets |
---|---|---|
来自 | SemanticSplitterNodeParser.get_nodes_from_documents() | tokenizer.encode_plus(..., return_offsets_mapping=True) |
单位 | 语义段落(语义切割) | 分词(token 切割) |
结果 | 返回每段文字的字符范围(start_char_idx, end_char_idx) | 返回每个 token 在原文中对应的字符偏移(start, end) |
用途 | 构建语义一致的 chunk,提升向量检索/摘要效果 | 编码成模型输入,训练或推理用 |
粒度 | 更大(按段落或语义组) | 更小(按词或子词) |
🔍 举例说明
假设文本是:
text = "今天天气很好,我想出去玩。"
token_offsets
(由 tokenizer 提供):
它可能会输出类似下面的字符位置偏移(以字为单位的起止):
[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7)]
# 即:['今', '天', '天', '气', '很', '好', ...]
每个 tuple 表示一个 token 对应的原始文本位置(start, end)。
nodes
(由语义分段器提供):
它可能会给出:
[(0, 6), (6, 9)]
# 表示第1段是 "今天天气很好",第2段是 "我想出去玩"
🧠 总结联系
token_offsets
是为了精确对齐模型输入和原文,比如训练模型、生成注意力掩码时使用。nodes
是为了找到语义上更合理的切割点,适合用于文本检索(像向量数据库的 chunk)、长文摘要等。- 二者都是基于字符位置工作的,token 是低层编码单位,node 是高层语义单位。
nodes
和 token_offsets
的索引计算都是基于原始文本中的字符位置(character offset)来进行的。但它们的粒度和目标不同。我们来具体说人话解释一下。
✅ 共同点:都基于字符位置(character offset)
无论是:
node.start_char_idx, node.end_char_idx
(语义切割)tokenizer.encode_plus(..., return_offsets_mapping=True)
中的offset_mapping
它们都表示在原始文本字符串中,从第几个字符到第几个字符。
例如,假设:
text = "今天天气很好,我想出去玩。"
↑012345678901234567
某个 offset 是 (0, 4)
就表示:从第 0 个字符到第 4 个字符,也就是 "今天天气"
。
🔍 不同点:切割目标不同
项目 | 切割目的 | 粒度 | 假设 offset 为 (0, 6) 意思是 |
---|---|---|---|
token_offsets | 把句子切成“词片”或子词,让模型能理解 | 字、词,甚至词的一部分(子词) | 第一个 token 是 "今天天气" |
nodes | 把整段文本按语义“逻辑分段”,提升语义检索准确性 | 按句子/段落/主题段落 | 第一个语义块是 "今天天气很好" |
🎯 举个实际例子
比如你想把一篇医学问答切块:
病人:我咳嗽一周了,还发烧怎么办?
医生:建议做个肺部CT,排除肺炎。注意多喝水。
nodes
会切成两段:
[
(0, 17), # “病人:我咳嗽一周了,还发烧怎么办?”
(17, 41) # “医生:建议做个肺部CT,排除肺炎。注意多喝水。”
]
token_offsets
会切出细粒度:
[
(0, 2), (2, 3), (3, 5), ..., (16, 17), ... # 每个词/字
]
✅ 所以结论:
- 两者索引方式都是以字符位置为基础的(start_char, end_char)
token_offsets
是让 BERT 等模型更好理解原文nodes
是为了切割成合适语义片段,给后续 embedding 用
如果你想“把 token 和 node 的切割对齐”,就可以通过字符 offset 把它们统一起来。