RAG / GraphRAG / 向量检索 面试题(完整答案版)

锚点项目:云创 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 的实践流水线:

  1. 文档解析:PDF / Word / Markdown → 纯文本 + 结构信息;
  2. 分块 + 向量化 → ChromaDB;
  3. LLM 抽取三元组(实体1, 关系, 实体2) JSON;
  4. 实体对齐:用别名表 + 编辑距离 + 向量相似映射到已有实体;
  5. 冲突消解:同实体多版本属性,按"来源时间 + 权威性"打分;
  6. 入库:实体 MERGE 防重,关系 CREATE;
  7. 异步管线:大文件入任务队列(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 正向。

相关推荐
渐儿2 小时前
后端 Python / Node 面试题(完整答案版)
面试
ricardo19733 小时前
代码分割 + 路由懒加载 + 字体子集化:前端瘦身三板斧
前端·面试
I Promise344 小时前
多传感器融合&模型后处理C++工程师面试参考回答
开发语言·c++·面试
涤生大数据5 小时前
大数据面试高频题:row_number() 数据倾斜到底怎么解决?
java·大数据·面试
摇滚侠5 小时前
HashMap 源码解析 底层原理 面试如何回答
java·面试·职场和发展
刀法如飞5 小时前
《理解道德经》简单版-第 1 章:道可道,非常道
前端·后端·面试
Moment6 小时前
开发Agent为什么必须先做意图识别?
前端·后端·面试
plainGeekDev6 小时前
Kotlin协程面试题:suspend原理都说不清,协程你真会用?
android·面试·kotlin
神奇小汤圆7 小时前
一个程序员眼中的 AI 核心概念,讲透 LLM 、Agent 、MCP 、Skill 、RAG...
面试