锚点项目:云创 DeepPark RAG 知识图谱问答系统(Neo4j + ChromaDB + LangChain)
一、GraphRAG 双路召回(核心亮点)
Q1:你的 GraphRAG 检索链路是怎么走的?
标准答案:
erlang
用户问题
│
├─→ LLM 实体抽取 + 查询改写(HyDE 可选)
│ │
│ ├─→ Neo4j:抽取实体作为锚点,2 跳子图查询(带关系类型过滤)
│ │ 结果:结构化关系三元组列表
│ │
│ └─→ ChromaDB:改写后的 query 向量化,HNSW 相似检索
│ 结果:相关文本片段 + 元数据
│
├─→ 相关度加权融合(图 0.6 / 向量 0.4,可按 query 类型动态调)+ 去重
│
└─→ Rerank(可选 bge-reranker)→ 拼装上下文 → LLM 流式生成 → WebSocket 推送
核心代码骨架:
python
class GraphRAG:
def __init__(self, neo4j, chroma, embedder, llm, reranker=None):
self.neo4j, self.chroma = neo4j, chroma
self.embedder, self.llm, self.reranker = embedder, llm, reranker
async def query(self, question: str):
entities, rewritten = await self._extract_and_rewrite(question)
# 双路并发
graph_task = asyncio.create_task(self._graph_recall(entities))
vec_task = asyncio.create_task(self._vector_recall(rewritten))
graph_ctx, vec_ctx = await asyncio.gather(graph_task, vec_task)
# 融合
merged = self._merge(graph_ctx, vec_ctx, w_graph=0.6, w_vec=0.4)
if self.reranker:
merged = await self.reranker.rerank(question, merged, top_k=10)
# 流式生成
async for chunk in self.llm.astream(self._build_prompt(question, merged)):
yield chunk
追问应对:
- Q:为啥不串行先查图再查向量? 串行总延迟 = T_graph + T_vec ≈ 800ms;并发 = max(T_graph, T_vec) ≈ 500ms。用户感知差距明显。代价是图和向量结果不能互相过滤(要在融合阶段做),但实测准确率几乎无损。
Q2:为什么要做双路召回?纯向量检索不够吗?
标准答案:
纯向量的短板:
- 多跳关系推理弱("A 的负责人的老板是谁");
- 缺结构化关系信息,只能给文本片段;
- 实体歧义无法消解("苹果"是公司还是水果)。
GraphRAG 补足:
- 图查询精确返回关系链;
- 向量提供语义相似的描述性内容;
- 二者融合:图给骨架,向量给血肉。
核心代码骨架(融合):
python
def _merge(self, graph_ctx: list, vec_ctx: list, w_graph=0.6, w_vec=0.4):
"""
graph_ctx: [{"text": "A --MANAGES--> B", "score": 0.9, "source": "graph"}]
vec_ctx: [{"text": "...段落...", "score": 0.85, "source": "vector"}]
"""
merged = []
seen = set() # 模糊去重
for item in graph_ctx:
item["score"] *= w_graph
merged.append(item)
seen.add(item["text"][:50])
for item in vec_ctx:
if item["text"][:50] in seen:
continue
item["score"] *= w_vec
merged.append(item)
merged.sort(key=lambda x: x["score"], reverse=True)
return merged
追问应对:
- Q:权重 0.6 / 0.4 怎么定? 初期凭经验;后来做 A/B------把 query 分类成"关系类"(图 0.7)和"描述类"(向量 0.6),动态调权后命中率提升 8%。极致做法是让 LLM 给 query 评分决定权重,但成本高,目前用简单规则即可。
Q3:实体抽取怎么做?怎么消歧?
标准答案:
- 抽取 :LLM Few-shot prompt,输出 JSON 数组
[{entity, type, mention}]; - 链接:mention → Neo4j 按别名表 + 编辑距离匹配 → 规范实体 ID;
- 消歧:上下文相关------优先匹配当前对话已出现的实体;多候选时用向量相似度排序;
- 失败兜底:低置信度走"猜测+确认"流程,让 LLM 反问用户。
核心代码骨架:
python
from rapidfuzz import fuzz
class EntityLinker:
def __init__(self, neo4j, embedder):
self.neo4j, self.embedder = neo4j, embedder
async def link(self, mention: str, context: list[str]):
# 1. 别名精确匹配
exact = await self.neo4j.run(
"MATCH (n:Entity) WHERE $m IN n.aliases RETURN n",
m=mention)
if exact: return exact[0]
# 2. 模糊匹配 top10 候选
candidates = await self.neo4j.run(
"MATCH (n:Entity) RETURN n.name AS name, n.id AS id, n.aliases AS aliases")
scored = [(c, max(fuzz.ratio(mention, a) for a in [c["name"]] + c["aliases"]))
for c in candidates]
top = sorted(scored, key=lambda x: -x[1])[:10]
if top[0][1] > 90: # 高置信度直接选
return top[0][0]
# 3. 用上下文 embedding 重排
ctx_vec = self.embedder.embed(" ".join(context))
for c, _ in top:
c["sim"] = cosine(ctx_vec, self.embedder.embed(c["name"]))
top.sort(key=lambda x: -x[0]["sim"])
if top[0][0]["sim"] > 0.7:
return top[0][0]
return None # 需要反问用户
追问应对:
- Q:别名表怎么维护? 三个来源:① 文档入库时 LLM 抽取实体的同时让它列别名;② 用户反馈"你说的 X 其实是 Y" 入别名;③ 运营定期合并相似实体。别名表是 GraphRAG 长期优化的关键。
Q4:2 跳子图怎么查?为什么是 2 跳不是 3 跳?
标准答案:
Cypher 示例:
cypher
MATCH (n:Entity {id: $entityId})-[r1]-(m)-[r2]-(o)
WHERE NOT type(r1) IN ['MENTION', 'CO_OCCUR'] // 排除噪声关系
AND type(r2) IN ['MANAGES', 'BELONGS_TO', 'DEPENDS_ON'] // 业务关心的关系
RETURN n, r1, m, r2, o
LIMIT 50
为什么 2 跳:
- 1 跳信息量不够(只有直接邻居);
- 3 跳爆炸(节点数指数增长,结果难融合);
- 2 跳是信息密度 / 噪声比的甜点;
- 真要 3 跳的场景,让 LLM 第二轮迭代查询(ReAct 式)。
核心代码骨架:
python
class GraphRecaller:
async def recall(self, entities: list[str], rel_whitelist: list[str]):
cypher = """
MATCH path = (n:Entity)-[r1]-(m)-[r2]-(o)
WHERE n.id IN $ids
AND type(r1) IN $rels AND type(r2) IN $rels
RETURN n, r1, m, r2, o,
// 相关度:路径中实体的 PageRank * 关系权重
n.pagerank * coalesce(r1.weight, 1.0) * coalesce(r2.weight, 1.0) AS score
ORDER BY score DESC
LIMIT 50
"""
records = await self.neo4j.run(cypher, ids=entities, rels=rel_whitelist)
return [self._triple_to_text(r) for r in records]
def _triple_to_text(self, r):
return {"text": f"{r['n']['name']} --[{type(r['r1'])}]→ {r['m']['name']} --[{type(r['r2'])}]→ {r['o']['name']}",
"score": r["score"], "source": "graph"}
追问应对:
- Q:3 跳查询如果真需要怎么实现? 不在一次 Cypher 里查,而是 ReAct 多轮:第 1 轮拿 2 跳结果 → LLM 判断"还要不要继续深入" → 第 2 轮以新实体为起点再查 2 跳。这样总深度可达 4-6 跳但成本可控。
Q5:图召回和向量召回怎么融合?
标准答案:
- 结构对齐:图三元组转自然语言;
- 去重:模糊去重(前 50 字符相同视为重复);
- 加权排序:按场景调权;
- 截断:按总 token 预算(如 3000 token)截断;
- 进阶:Cross-Encoder Rerank(bge-reranker-large)做最终排序。
核心代码骨架(带 token 预算的截断):
python
import tiktoken
class ContextBuilder:
def __init__(self, max_tokens=3000, encoder_name="cl100k_base"):
self.max_tokens = max_tokens
self.enc = tiktoken.get_encoding(encoder_name)
def build(self, items: list[dict], question: str):
# items 已按 score 降序
used, picked = 0, []
for item in items:
tokens = len(self.enc.encode(item["text"]))
if used + tokens > self.max_tokens:
continue # 跳过过大的,不直接截断(保完整性)
picked.append(item)
used += tokens
ctx = "\n\n".join(f"[{i+1}] {p['text']} (来源: {p['source']})"
for i, p in enumerate(picked))
return ctx
追问应对:
- Q:Rerank 模型成本怎么权衡? bge-reranker-base 100ms 内能 rerank 50 条,加约 10% 延迟换 5~15% 准确率提升,性价比高。但要部署到 GPU 服务,CPU 太慢。我们用 Triton 部署,多业务复用。
二、流式生成与并发
Q6:你说的 "async generator + 边生成边推送" 具体怎么实现?
标准答案:
- 后端
httpx.AsyncClient.stream拉 LLM SSE →async for line解析 →yield chunk; - WebSocket handler 消费 generator →
ws.send_json推; - 客户端断开 →
WebSocketDisconnect→ 上游 context 自动退出释放连接。
核心代码骨架:
python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import httpx, json
app = FastAPI()
async def stream_llm(prompt: str):
async with httpx.AsyncClient(timeout=60) as client:
async with client.stream("POST", LLM_URL, json={
"model": "gpt-4", "messages": [{"role": "user", "content": prompt}],
"stream": True
}, headers={"Authorization": f"Bearer {API_KEY}"}) as resp:
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
break
try:
delta = json.loads(payload)["choices"][0]["delta"].get("content")
if delta:
yield delta
except (json.JSONDecodeError, KeyError):
continue
@app.websocket("/ws/chat")
async def chat(ws: WebSocket):
await ws.accept()
try:
data = await ws.receive_json()
prompt = await build_rag_prompt(data["question"])
async for chunk in stream_llm(prompt):
await ws.send_json({"type": "delta", "data": chunk})
await ws.send_json({"type": "done"})
except WebSocketDisconnect:
# context manager 自动关 upstream
pass
追问应对:
- Q:客户端关闭后上游一定能立即停吗? 不保证立即------
async with上下文退出会调用aclose(),httpx 会发 RST 给 LLM 厂商,但厂商侧是否立即停止取决于实现。我们额外用asyncio.Event做软取消,每个 chunk 检查一次。
Q7:信号量与超时取消怎么设计的?
标准答案:
- 并发限制 :
asyncio.Semaphore(20)限同时 LLM 调用数; - 超时取消 :
asyncio.wait_for(call, timeout=30); - 级联取消 :捕获
CancelledError后主动 close 上游 HTTP,避免下游继续算; - 资源释放:finally 释放 semaphore、关 ws、清缓存。
核心代码骨架:
python
import asyncio
class LLMClient:
def __init__(self, max_concurrency=20):
self.sem = asyncio.Semaphore(max_concurrency)
async def call(self, prompt: str, timeout=30):
async with self.sem:
try:
return await asyncio.wait_for(self._do_call(prompt), timeout=timeout)
except asyncio.TimeoutError:
logger.warning("llm timeout, prompt_head=%s", prompt[:50])
raise
except asyncio.CancelledError:
logger.info("client cancelled llm call")
raise # 必须 re-raise 才能让 task 真的取消
async def _do_call(self, prompt):
async with httpx.AsyncClient() as client:
resp = await client.post(LLM_URL, json={"prompt": prompt})
resp.raise_for_status()
return resp.json()
追问应对:
- Q:信号量数 20 怎么定? 压测出 LLM 厂商单 key 稳定不限流的并发上限大约 25,留 20% 余量取 20。后来切多 key 后改用令牌桶限流,按 key 维度分流。
Q8:Redis 是怎么用的?
标准答案:
- 热点实体抽取缓存:相同 query 抽取结果缓存(TTL 1h),命中率 ~40%;
- 召回结果缓存 :
(query_hash, 实体集) → 上下文,命中直返; - 多轮会话上下文 :
LIST存最近 N 轮; - 流式断点续传:进度写 Redis,前端断线重连从断点续 send;
- 限流计数器 :
INCR + EXPIRE; - 分布式锁 :
SET NX PX,防热点并发重复算。
核心代码骨架(断点续传):
python
class StreamSession:
def __init__(self, redis, session_id):
self.r, self.sid = redis, session_id
self.key = f"stream:{session_id}"
async def push(self, chunk: str):
await self.r.rpush(self.key, chunk)
await self.r.expire(self.key, 600)
async def replay(self, start_index: int = 0):
"""前端重连时调用,返回已生成的所有 chunk"""
return await self.r.lrange(self.key, start_index, -1)
async def done(self):
await self.r.rpush(self.key, "__END__")
await self.r.expire(self.key, 60)
# WebSocket handler
@app.websocket("/ws/chat/{sid}")
async def chat(ws: WebSocket, sid: str):
await ws.accept()
session = StreamSession(redis, sid)
data = await ws.receive_json()
resume_from = data.get("resume_from")
if resume_from is not None:
# 重连场景,先 replay
for chunk in await session.replay(resume_from):
await ws.send_json({"chunk": chunk})
return
# 新会话,正常生成
async for chunk in stream_llm(data["question"]):
await session.push(chunk)
await ws.send_json({"chunk": chunk})
await session.done()
追问应对:
- Q:缓存 key 怎么设计避免误命中? key 包含
(model, prompt_template_version, query_normalized, knowledge_base_version)。任何一项变更都不命中。query 归一化包括去多余空格、统一中英文标点、去停用词后取 md5。
三、向量库与检索基础
Q9:ChromaDB / pgvector / Milvus 怎么选?
标准答案:
| 维度 | ChromaDB | pgvector | Milvus |
|---|---|---|---|
| 部署 | 极简(嵌入式 / Docker) | 已有 PG 加扩展 | 单独集群 |
| 规模 | 百万级 | 千万级 | 亿级 |
| 与业务库 | 分离 | 同一事务 | 分离 |
| 索引 | HNSW | HNSW / IVFFlat | HNSW / IVF / DiskANN |
| 适合 | POC / 中小项目 | 中等规模 + 强事务 | 大规模 + 高 QPS |
DeepPark 用 ChromaDB:项目规模 < 100w 文档、部署简单、LangChain 无缝集成。
核心代码骨架(pgvector 示例对照):
sql
-- pgvector
CREATE EXTENSION vector;
CREATE TABLE chunks (
id BIGSERIAL PRIMARY KEY,
doc_id INT,
content TEXT,
embedding vector(1024)
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
-- 检索(带元数据过滤,体现 pgvector 优势)
SELECT id, content, 1 - (embedding <=> $1::vector) AS sim
FROM chunks
WHERE doc_id IN (SELECT id FROM doc WHERE tenant_id = $2)
ORDER BY embedding <=> $1::vector
LIMIT 10;
python
# ChromaDB
import chromadb
client = chromadb.PersistentClient(path="./chroma")
collection = client.get_or_create_collection("kb")
collection.add(ids=["1", "2"], documents=["...", "..."],
embeddings=[[...], [...]], metadatas=[{"doc_id": 1}, {"doc_id": 1}])
results = collection.query(query_embeddings=[[...]], n_results=10,
where={"doc_id": 1})
追问应对:
- Q:百万规模会卡吗? ChromaDB 百万级 HNSW 索引在 16G 内存下检索 P95 < 50ms,OK。到千万级别建议切 pgvector / Milvus,主要瓶颈是内存装不下索引导致频繁换页。
Q10:Embedding 模型怎么选?怎么评估?
标准答案:
- 选型考量:维度、语种、领域、推理速度、成本;
- 常用模型:bge-large-zh-v1.5、text-embedding-3-small、m3e-base、Cohere embed-multilingual-v3、bge-m3(多语+多粒度);
- 评估方法 :
- 业务相关性集(query / 正样本 / 负样本)算 NDCG@10、Recall@10;
- 同义改写检索一致性测试;
- A/B 上线后看点击/采纳率。
核心代码骨架(评估):
python
import numpy as np
def ndcg_at_k(relevances: list[int], k: int):
rel = np.array(relevances[:k])
dcg = np.sum(rel / np.log2(np.arange(2, len(rel) + 2)))
ideal = np.sort(rel)[::-1]
idcg = np.sum(ideal / np.log2(np.arange(2, len(ideal) + 2)))
return dcg / idcg if idcg > 0 else 0
async def eval_embedder(embedder, eval_set, vec_store):
scores = []
for case in eval_set:
# case = {"query": ..., "relevant_ids": [1,2,3]}
q_vec = embedder.embed(case["query"])
results = await vec_store.search(q_vec, k=10)
relevances = [1 if r["id"] in case["relevant_ids"] else 0 for r in results]
scores.append(ndcg_at_k(relevances, 10))
return {"ndcg@10": np.mean(scores)}
追问应对:
- Q:中文场景为啥不直接用 OpenAI embedding? text-embedding-3-large 中文也不错,但成本高(按 token 计费)、走外网延迟。私有部署 bge-large-zh 性能接近、可控、免费,DeepPark 一直用 bge 系。
Q11:Chunk 策略怎么定?
标准答案:
- 按语义切 :先按章节/段落,再 token 窗口(512)切片,重叠 50 token;
- 按结构切:Markdown 按 H2/H3,代码按函数;
- 按层级:父子 chunk(父给上下文,子给检索粒度);
- 元数据 :每个 chunk 带
doc_id, page, section, tags便于过滤; - DeepPark 实践:技术文档按二级标题切 + 500 token 滑窗。
核心代码骨架:
python
class SemanticChunker:
def __init__(self, max_tokens=500, overlap=50, encoder="cl100k_base"):
self.enc = tiktoken.get_encoding(encoder)
self.max_tokens, self.overlap = max_tokens, overlap
def chunk(self, text: str, meta: dict) -> list[dict]:
# 先按段落切
paras = [p.strip() for p in text.split("\n\n") if p.strip()]
chunks, current, current_tokens = [], [], 0
for p in paras:
t = len(self.enc.encode(p))
if current_tokens + t > self.max_tokens and current:
chunks.append(self._make(current, meta))
# 重叠:保留尾部
tail = self._tail(current, self.overlap)
current, current_tokens = tail, sum(len(self.enc.encode(x)) for x in tail)
current.append(p)
current_tokens += t
if current:
chunks.append(self._make(current, meta))
return chunks
def _make(self, paras, meta):
return {"text": "\n\n".join(paras), **meta}
def _tail(self, paras, n_tokens):
out, t = [], 0
for p in reversed(paras):
t += len(self.enc.encode(p))
out.insert(0, p)
if t >= n_tokens: break
return out
追问应对:
- Q:太大的 chunk 和太小的各有什么问题? 太大:检索精度下降(不相关的内容稀释了相关信号);上下文塞不下太多 chunk。太小:召回率高但碎片化,模型拼不出完整答案。500 token 是经验最优,复杂技术文档可以到 800。
Q12:怎么处理"检索到的内容不够 / 不准"?
标准答案:
- 不够:放宽召回(topK 调大)→ 改写 query(HyDE:让 LLM 先写假答案再检索)→ 多轮检索(ReAct 迭代);
- 不准:加 rerank(bge-reranker)→ 加过滤元数据 → 调融合权重;
- 训练数据反哺:用户标"答非所问"的 case 收集起来,定期清洗 chunk 或换 embedding。
核心代码骨架(HyDE):
python
async def hyde_query(question: str, llm, embedder, vec_store):
"""让 LLM 先写一段假设的答案,再用答案的 embedding 去检索"""
hyp = await llm.complete(
f"假设你已经知道答案,写一段100字以内的回答(即使不确定):\n问题: {question}\n回答:",
temperature=0.3, max_tokens=150
)
# 用假答案的 embedding 检索 ------ 比 query 的 embedding 召回质量更高
hyp_vec = embedder.embed(hyp)
return await vec_store.search(hyp_vec, k=10)
追问应对:
- Q:HyDE 不会因为假答案有错引入噪声吗? 会有风险,所以我们做"双 query 检索"------query 本身 + HyDE 假答案各检索一次,结果合并去重。实测 NDCG 提升 6~10%。但延迟翻倍,看场景取舍。
四、知识图谱基础
Q13:Neo4j 表结构(图模型)怎么设计?
标准答案:
- 节点 :实体 + 标签 + 属性。如
(:Person {name, role})、(:Equipment {id, type}); - 关系 :有向有类型。如
(:Person)-[:MANAGES]->(:Equipment); - 索引:常用查询字段建索引;
- 唯一约束:业务主键加唯一约束防重复;
- 建模原则:关系优先于属性(能成关系就别塞属性),便于多跳查询。
核心代码骨架:
cypher
// 唯一约束 + 索引
CREATE CONSTRAINT person_id FOR (p:Person) REQUIRE p.id IS UNIQUE;
CREATE INDEX equipment_name FOR (e:Equipment) ON (e.name);
// 全文索引(实体检索)
CREATE FULLTEXT INDEX entityFulltext FOR (n:Entity) ON EACH [n.name, n.aliases];
// MERGE 防重创建
MERGE (p:Person {id: $pid})
SET p.name = $name, p.updated_at = timestamp()
MERGE (e:Equipment {id: $eid})
SET e.name = $ename
MERGE (p)-[r:MANAGES]->(e)
SET r.since = $since;
python
# Python 驱动调用
from neo4j import AsyncGraphDatabase
driver = AsyncGraphDatabase.driver(URI, auth=(USER, PWD))
async def upsert_relation(pid, name, eid, ename, since):
async with driver.session() as s:
await s.run("""
MERGE (p:Person {id: $pid}) SET p.name = $name
MERGE (e:Equipment {id: $eid}) SET e.name = $ename
MERGE (p)-[r:MANAGES]->(e) SET r.since = $since
""", pid=pid, name=name, eid=eid, ename=ename, since=since)
追问应对:
- Q:什么时候用属性 vs 关系? 准则:① 这个值是否会被查询/遍历------是 → 关系;② 这个值是否有自己的属性------有 → 关系;③ 这个值是否多对多------是 → 关系。比如"用户喜欢的标签"是关系,"用户年龄"是属性。
Q14:Cypher 查询常用模式?
标准答案:
cypher
-- 1. 多跳路径
MATCH path = (a:Person {name:'张三'})-[:MANAGES*1..3]->(b)
RETURN path
-- 2. 共邻发现(朋友的朋友)
MATCH (a)-[:KNOWS]-(c)-[:KNOWS]-(b)
WHERE a <> b
RETURN c, count(*) AS common_count
ORDER BY common_count DESC
-- 3. 子图导出
MATCH (n:Project {id:$id})-[r*1..2]-(m)
RETURN n, r, m
-- 4. 全文检索
CALL db.index.fulltext.queryNodes('entityFulltext', $keyword)
YIELD node, score
RETURN node, score LIMIT 10
-- 5. PageRank(GDS 库)
CALL gds.pageRank.stream({
nodeProjection: 'Entity',
relationshipProjection: 'MANAGES'
}) YIELD nodeId, score
RETURN gds.util.asNode(nodeId).name AS name, score
ORDER BY score DESC LIMIT 20
追问应对:
- Q:变长路径
*1..3性能怎么样? 路径深度每加 1,结果可能指数增长。必加 LIMIT、关系类型白名单、起点必须建索引。深度 > 4 基本不可用,改 GDS 算法做。
Q15:怎么把非结构化文档变成知识图谱?
标准答案:
DeepPark 的实践流水线:
- 文档解析:PDF / Word / Markdown → 纯文本 + 结构信息;
- 分块 + 向量化 → ChromaDB;
- LLM 抽取三元组 :
(实体1, 关系, 实体2)JSON; - 实体对齐:用别名表 + 编辑距离 + 向量相似映射到已有实体;
- 冲突消解:同实体多版本属性,按"来源时间 + 权威性"打分;
- 入库:实体 MERGE 防重,关系 CREATE;
- 异步管线:大文件入任务队列(Celery)后台处理,前端轮询进度。
核心代码骨架:
python
from pydantic import BaseModel
class Triple(BaseModel):
subject: str
relation: str
object: str
subject_type: str
object_type: str
confidence: float
EXTRACT_PROMPT = """从下面的文本抽取知识三元组(主体-关系-客体),输出 JSON 数组。
关系限定为: MANAGES, BELONGS_TO, DEPENDS_ON, LOCATED_AT
每条带 confidence (0-1)。
文本: {text}
只输出 JSON,无解释。"""
async def extract_and_persist(text: str, doc_meta: dict, llm, neo4j, linker):
resp = await llm.json(EXTRACT_PROMPT.format(text=text))
triples = [Triple(**t) for t in resp]
for t in triples:
if t.confidence < 0.6: continue
s_id = await linker.link_or_create(t.subject, t.subject_type)
o_id = await linker.link_or_create(t.object, t.object_type)
await neo4j.run(f"""
MATCH (s {{id: $sid}}), (o {{id: $oid}})
MERGE (s)-[r:{t.relation}]->(o)
SET r.source = $doc, r.confidence = $conf, r.updated_at = timestamp()
""", sid=s_id, oid=o_id, doc=doc_meta["id"], conf=t.confidence)
追问应对:
- Q:抽取出错(错关系/错实体)怎么治理? 三道关:① 抽取时让 LLM 打 confidence,低分丢弃;② 入库时关系类型白名单卡死,非白名单拒绝;③ 上线后定期跑"图一致性检查"(如同一对实体被打了相反关系),人工 review。
五、可能被追问
-
Q:你说的"幻觉降低"在 RAG 项目里怎么量?
业务规则 + 抽样人审:规则覆盖"引用是否在上下文中出现"、"数字是否一致",每周抽 100 条人审。规则能筛掉 70% 明显错误,剩下交给人审。
-
Q:图召回 50 条上下文太多怎么办?
Cypher 层 LIMIT + 关系白名单先过滤;融合阶段 LLM rerank 取 top 10;最终进 prompt 的图三元组一般不超过 15 条。
-
Q:双路并发会不会浪费资源?
约 20% 资源"用不到",但延迟省 200~500ms 用户感知很明显。热路径缓存后浪费降到 5% 以内,整体 ROI 正向。