不是。Embedding 只完成 RAG 中的 Retrieval(检索)部分:
flowchart LR Q["用户问题"] --> E["Embedding"] E --> V["向量检索"] V --> R["Reranker"] R --> C["Top-K 原文"] C --> L["大语言模型"] Q --> L L --> A["基于证据的最终答案"]
如果系统只返回 Top-K 文档,它是语义搜索或检索系统,还没有完成完整的 RAG 回答。
用户通常不想看到一组零零散散的原始文档片段。检索结果可能来自不同页面,包含重复内容、上下文缺失,甚至存在相互冲突的表述。它们是供系统使用的证据,不是最终答案。
因此,还需要把用户问题和 Top-K 原文交给大语言模型。大语言模型需要筛选有效证据、合并重复信息、补全上下文,并将内容重新组织成连贯、直接且带有来源引用的回答。这个 Generation 阶段才把 “检索到资料” 变成 “回答用户问题”。
完整 RAG 至少包含:
- Retrieval:Embedding 和向量数据库召回候选;
- Ranking:可选的 Reranker 对候选精排;
- Augmentation:把 Top-K 原文和用户问题组成上下文;
- Generation:大语言模型阅读上下文并生成答案。
Embedding 模型不会:
- 阅读多条证据并消除重复;
- 汇总分散在不同 chunk 中的信息;
- 判断证据是否足以回答问题;
- 将检索结果组织成自然语言答案;
- 在回答中引用来源。
这些工作由最终的大语言模型完成。
将 Top-K 交给大语言模型
Reranker 返回 results 后,可以构造最小上下文:
context = "\n\n".join(
(
f"[{index}] source={result['source']} "
f"page={result['page']}\n{result['text']}"
)
for index, result in enumerate(results, start=1)
)
messages = [
{
"role": "system",
"content": (
"只根据给定资料回答。资料不足时明确说不知道,"
"并使用 [序号] 标注引用。"
),
},
{
"role": "user",
"content": f"问题:{query}\n\n资料:\n{context}",
},
]随后将 messages 发送给大语言模型的 Chat Completions 接口。模型看到的是 Top-K 的原文和元数据,不是 Embedding 向量。
同一个问题对比 Vector RAG 和 GraphRAG
先按 向量数据库 完成 1706.03762v7.txt 的 LanceDB 建表、LadybugDB 建图以及实体关系抽取,然后用同一个问题比较两条链路:
flowchart LR Q["同一个问题"] --> E["同一个 Embedding 模型"] E --> V["LanceDB Top-20"] V --> R["同一个 Reranker<br/>Top-5"] R --> VC["Vector RAG<br/>命中 chunk"] R --> G["LadybugDB 图扩展"] G --> GC["GraphRAG<br/>chunk + 相邻原文 + 实体关系"] VC --> L1["同一个 LLM"] GC --> L2["同一个 LLM"]
这个实验固定问题、向量候选、Reranker、LLM、Prompt、温度和最大输出长度。唯一变量是交给 LLM 的上下文:
- Vector RAG:只使用 LanceDB 召回的 Top-K chunk;
- GraphRAG:从同一批 chunk 出发,增加
NEXT相邻原文、MENTIONS实体、RELATED_TO关系及关系的证据 chunk。
import json
import urllib.request
import ladybug as lb
import lancedb
EMBED_URL = "http://127.0.0.1:8001/v1/embeddings"
EMBED_MODEL = "Qwen/Qwen3-Embedding-8B"
RERANK_URL = "http://127.0.0.1:8002/v1/rerank"
RERANK_MODEL = "Qwen/Qwen3-Reranker-8B"
CHAT_URL = "http://127.0.0.1:8004/v1/chat/completions"
CHAT_MODEL = "qwen-w4a8"
QUESTION = (
"Why can the Transformer remove recurrence without losing "
"sequence-order information, and what computational trade-offs "
"does this introduce compared with recurrent layers?"
)
TOP_N = 20
TOP_K = 5
CONTEXT_LIMIT = 12_000
def post(url, body):
request = urllib.request.Request(
url,
data=json.dumps(body, ensure_ascii=False).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response)
def embed(text):
response = post(
EMBED_URL,
{
"model": EMBED_MODEL,
"input": [text],
"encoding_format": "float",
},
)
return response["data"][0]["embedding"]
def rerank(documents, top_n):
return post(
RERANK_URL,
{
"model": RERANK_MODEL,
"query": QUESTION,
"documents": documents,
"top_n": top_n,
},
)["results"]
def ask_llm(context):
response = post(
CHAT_URL,
{
"model": CHAT_MODEL,
"messages": [
{
"role": "system",
"content": (
"Answer only from the supplied evidence. "
"If it is insufficient, say so. Cite evidence labels."
),
},
{
"role": "user",
"content": f"Question: {QUESTION}\n\nEvidence:\n{context}",
},
],
"chat_template_kwargs": {"enable_thinking": False},
"temperature": 0,
"max_tokens": 500,
},
)
return response["choices"][0]["message"]["content"]
vector_db = lancedb.connect("./data/lancedb")
table = vector_db.open_table("attention_paper")
graph_db = lb.Database("./data/attention_graph.lbdb")
graph = lb.Connection(graph_db)
vector_candidates = (
table.search(embed(QUESTION)).metric("cosine").limit(TOP_N).to_list()
)
vector_hits = [
{
**vector_candidates[item["index"]],
"rerank_score": item["relevance_score"],
}
for item in rerank(
[candidate["text"] for candidate in vector_candidates],
TOP_K,
)
]
vector_context = "\n\n".join(
(f"[V{index}] source={hit['source']} page={hit['page']}\n{hit['text']}")
for index, hit in enumerate(vector_hits, start=1)
)[:CONTEXT_LIMIT]两条链路复用同一个 vector_hits,因此 GraphRAG 的差异不是来自另一轮向量检索。
从命中 chunk 扩展图上下文
图中的多个 OPTIONAL MATCH 如果写在同一条查询中,会把相邻 chunk、出边和入边做笛卡尔组合。这次实测产生了 7201 行中间结果,因此改为三条小查询。图关系不能在排序前直接 LIMIT,否则可能截掉与问题相关的关系;先取出一跳候选,再用同一个 Reranker 选 Top-20:
hit_ids = [hit["id"] for hit in vector_hits]
adjacent_chunks = (
graph.execute(
"""
UNWIND $ids AS id
MATCH (hit:Chunk {id: id})-[:NEXT]-(adjacent:Chunk)
RETURN DISTINCT adjacent.id AS id, adjacent.text AS text
LIMIT 6
""",
parameters={"ids": hit_ids},
)
.rows_as_dict()
.get_all()
)
outgoing = (
graph.execute(
"""
UNWIND $ids AS id
MATCH (hit:Chunk {id: id})-[:MENTIONS]->(entity:Entity)
-[relation:RELATED_TO]->(related:Entity)
RETURN DISTINCT entity.name AS source,
relation.kind AS kind,
related.name AS target,
relation.evidence_chunk_id AS evidence_id
""",
parameters={"ids": hit_ids},
)
.rows_as_dict()
.get_all()
)
incoming = (
graph.execute(
"""
UNWIND $ids AS id
MATCH (hit:Chunk {id: id})-[:MENTIONS]->(entity:Entity)
<-[relation:RELATED_TO]-(related:Entity)
RETURN DISTINCT related.name AS source,
relation.kind AS kind,
entity.name AS target,
relation.evidence_chunk_id AS evidence_id
""",
parameters={"ids": hit_ids},
)
.rows_as_dict()
.get_all()
)
fact_candidates = outgoing + incoming
fact_texts = [
f"{fact['source']} -[{fact['kind']}]-> {fact['target']}" for fact in fact_candidates
]
facts = [
{
**fact_candidates[item["index"]],
"text": fact_texts[item["index"]],
"rerank_score": item["relevance_score"],
}
for item in rerank(fact_texts, 20)
]
evidence_ids = list(
dict.fromkeys(fact["evidence_id"] for fact in facts if fact["evidence_id"])
)[:5]
relation_evidence = (
graph.execute(
"""
UNWIND $ids AS id
MATCH (chunk:Chunk {id: id})
RETURN chunk.id AS id, chunk.text AS text
""",
parameters={"ids": evidence_ids},
)
.rows_as_dict()
.get_all()
)
graph_parts = [vector_context]
graph_parts.extend(
f"[K{index}] {fact['text']}" for index, fact in enumerate(facts, start=1)
)
graph_parts.extend(
f"[E{index}] relation evidence\n{row['text']}"
for index, row in enumerate(relation_evidence, start=1)
)
graph_parts.extend(
f"[A{index}] adjacent chunk\n{row['text']}"
for index, row in enumerate(adjacent_chunks, start=1)
)
graph_context = "\n\n".join(graph_parts)[:CONTEXT_LIMIT]CONTEXT_LIMIT 给两条链路相同的最大字符预算,避免 GraphRAG 仅靠无限增加上下文取得优势。当前 Chat 模型的上下文上限是 262144 tokens,本次实测使用 12000 字符输入和 500 tokens 输出;更严格的实验应按实际 tokenizer 限制 token,而不是字符。当前模型默认启用 thinking,样例显式关闭,否则较小的输出预算可能全部用于推理而没有最终 content。
调用同一个 LLM
vector_answer = ask_llm(vector_context)
graph_answer = ask_llm(graph_context)
print("=== Vector RAG ===")
print(vector_answer)
print("\n=== GraphRAG ===")
print(graph_answer)本次实测结果
使用当前 Chat 模型重新建图后,数据包含 81 个 chunk、523 个实体、771 条 MENTIONS 和 721 条 RELATED_TO。LanceDB 先召回 20 个候选,Reranker 选出的 chunk_index 是 8、35、3、12、29;图中得到 265 条一跳候选关系,再精排到 20 条。Vector RAG 上下文为 2852 字符,GraphRAG 扩展后为 10121 字符。
Vector RAG 输出:
The Transformer eschews recurrence and relies entirely on self-attention to compute representations [V1, V4]. However, the provided text does not explicitly explain the specific mechanism (such as positional encoding) by which sequence order is preserved. Therefore, the evidence is insufficient to fully explain how order information is retained.
Self-attention layers require O(1) sequential operations, whereas recurrent layers require O(n) [V2, V5]. The complexity per layer for self-attention is O(n² · d), while for recurrent layers it is O(n · d²) [V5]. Self-attention has a maximum path length of O(1), whereas recurrent layers have a maximum path length of O(n) [V5].
GraphRAG 输出:
The Transformer uses positional encoding to preserve sequence order. Specifically, the evidence notes that “Noam proposed… the parameter-free position representation” [V3]. While the Transformer eschews recurrence to compute representations [V4], it relies on this position representation to align positions, as the self-attention mechanism itself connects all positions with a constant number of sequentially executed operations [A4], [V2].
The Transformer allows for significantly more parallelization compared to recurrent layers, which are inherently sequential [A3], [A6]. Self-attention requires O(1) sequential operations versus O(n), has an O(1) maximum path length versus O(n), and has O(n² · d) per-layer complexity versus O(n · d²) [V1], [V2], [A4].
Vector RAG 命中了复杂度表所在 chunk 29,但该 chunk 只包含 Positional Encoding 标题,没有后续解释,因此它正确地报告证据不足。GraphRAG 通过 NEXT 扩展到后续 chunk,补出了位置编码,使答案覆盖 “顺序信息” 和 “计算权衡” 两部分。这个问题比原来的 Multi-Head Attention 问题更能体现图扩展的价值。
重点比较以下内容:
| 观察项 | Vector RAG | GraphRAG |
|---|---|---|
| 对局部机制的描述 | 通常取决于命中 chunk 是否完整 | 可通过 NEXT 补充前后定义 |
| 跨 chunk 信息 | 容易遗漏未进入 Top-K 的关联内容 | 可沿实体关系取回相关证据 |
| 关系表达 | LLM 从原文临时归纳 | 显式提供实体及关系 |
| 可追溯性 | 引用向量命中的 chunk | 还能追踪关系的 evidence_chunk_id |
| 噪声来源 | 向量近邻可能语义相似但无关 | 还会受到实体抽取和关系抽取错误影响 |
| 成本和延迟 | 较低 | 增加图查询和更多上下文 |
不能预设 GraphRAG 一定更好。如果 Top-K 已经完整包含答案,两者可能几乎相同;当问题需要跨 chunk、跨实体或多跳关系时,GraphRAG 才更可能体现优势。比较时还应检查引用是否真的支持答案,不能只比较语言是否更流畅。
我们的 GraphRAG 与知识图谱的关系
知识图谱是数据层,用实体、属性和关系组织知识;GraphRAG 是使用图结构检索证据并生成答案的流程,两者不是同一层级的概念。
| 知识图谱 | 我们的 GraphRAG | |
|---|---|---|
| 本质 | 一种数据组织形式 | 一条检索并生成答案的流程 |
| 内容 | 实体、属性和实体关系 | chunk、相邻关系、实体关系及原文证据 |
| 输入 | 查询语句或图遍历条件 | 用户的自然语言问题 |
| 输出 | 节点、关系或路径 | LLM 根据检索证据生成的答案 |
| 是否需要 LLM | 不需要 | 需要 |
| 是否需要向量检索 | 不一定 | 当前实现需要 |
在这个样例中,LadybugDB 保存的 Entity、RELATED_TO 及其证据构成了一个简化的知识图谱。GraphRAG 先通过向量检索找到相关 chunk,再利用该图谱扩展实体关系、相邻原文和证据 chunk,最后交给 LLM 生成答案。因此,知识图谱是当前 GraphRAG 的一部分,而 GraphRAG 是知识图谱在 RAG 流程中的一种应用。
与微软 GraphRAG 的关系
我们的实现可以看作微软 GraphRAG 的简化版,更准确地说,它接近微软的 Local Search:两者都将向量检索、知识图关系和原始文本组合成上下文,再交给 LLM 生成答案。
| 微软 GraphRAG | 我们的实现 |
|---|---|
| 从查询映射到相关实体 | 从查询向量召回相关 chunk |
| 组合实体、关系、Text Unit 和社区报告 | 扩展实体关系、证据 chunk 和相邻原文 |
| 支持 Local Search | 支持轻量的局部图扩展 |
| 通过社区发现、分层摘要支持 Global Search | 尚未构建社区和社区摘要 |
因此,这不是对微软代码的直接裁剪,而是沿用了相同的核心思路,并省略了社区发现、分层社区报告、Global Search 和 DRIFT Search。当前实现更适合验证图扩展能否补足 Top-K chunk 的上下文;需要回答全库主题归纳等全局问题时,再考虑增加社区层。