Agentic RAG 实战:PostgreSQL + LangGraph 一条链路

在前面的两篇文章中:

第一篇围绕 Agentic RAG,逐步梳理了四项核心能力:

  • 基础检索:从知识库寻找相关证据,再基于证据生成回答;
  • 查询路由:先判断问题是否需要查库,让简单问题直接回答;
  • 分步检索:将复杂问题拆成多个子问题,再逐个寻找证据;
  • 联网补充:本地资料不足时,切换到网络搜索补充信息。

第二篇转向 PostgreSQL + pgvector,验证了一条更集中的单库向量检索方案:

  • 无需双库:业务数据、文档元数据和向量可以保存在同一套数据库中;
  • 无需双写:一次写入即可同时维护文本、元数据和向量,减少 ID 映射与跨库协调。

前面的 Agentic RAG 示例采用 Milvus 存储向量,并将四项能力拆成四个独立流程。这样方便逐个理解,但代码相互独立,还没有形成一条完整的 Agentic RAG 链路。

接下来,把四项能力收拢到同一条流程里。

将基础检索、查询路由、分步检索、联网补充整合到同一套 LangGraph 流程中,由用户提问与已有证据动态驱动分支决策。向量存储不再使用 Milvus,改用 PostgreSQL + pgvector 实现。

在整个链路中,仅依靠 pgvector 的向量相似度做检索,面对专有名词时召回效果不稳定。因此在语义向量召回的基础上,新增 Elasticsearch 关键词检索;两路检索结果送入重排模型(Rerank)做精排,最终构成 pgvector + ES → Rerank 的混合检索链路。

pgvector 向量入库

这里不再启动 Milvus,而是使用带 pgvector 扩展的 PostgreSQL 镜像:

yaml 复制代码
services:
  postgres:
    image: pgvector/pgvector:pg16
    container_name: rag_pg_vector_db
    restart: always
    environment:
      POSTGRES_USER: copyer
      POSTGRES_PASSWORD: 123456
      POSTGRES_DB: copyer-rag-pg-db
    ports:
      - "5432:5432"
    volumes:
      - ./volumes/postgres:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d

pgvector/pgvector:pg16 已经包含向量扩展。容器第一次启动时,init-scripts 会执行 sql 文件,创建表(eg: travel_knowledge):

  • id 是主键
  • content 保存文本切片
  • metadata 保存来源和切片编号
  • vector 保存 1024 维向量,同时为向量列建立 HNSW 余弦索引。

接着安装操作数据库需要的依赖:

bash 复制代码
uv add psycopg[binary] langchain-postgres

# psycopg: Python 的 PostgreSQL 驱动,用于执行普通 SQL;

# langchain-postgres:把文档写入和相似度查询封装成 LangChain 的 VectorStore API,底层仍然使用 PostgreSQL + pgvector。

为什么还需要 langchain-postgres

第二篇文章直接使用 SQL 编写向量写入和相似度查询。这次需要把 PostgreSQL 接入 LangChain,因此增加 langchain-postgres 作为适配层,将这些操作封装成统一的 add_documents / add_embeddingssimilarity_search_with_score API。真正负责向量存储与检索的仍然是 PostgreSQL + pgvector

然后创建 PGVectorStore。这里主要是在告诉 LangChain:连接哪个数据库、操作哪张表,以及各字段如何映射。

python 复制代码
from langchain_openai import OpenAIEmbeddings
from langchain_postgres import PGEngine, PGVectorStore
from langchain_postgres.v2.indexes import DistanceStrategy

from src.config import (
    AI_EMBEDDING_BASE_URL,
    AI_EMBEDDING_KEY,
    AI_EMBEDDING_MODEL,
    get_postgres_url,
)

# 创建向量模型(dimensions 要和表里的 vector(1024) 一致)
embeddings = OpenAIEmbeddings(
    model=AI_EMBEDDING_MODEL,
    dimensions=1024,
    api_key=AI_EMBEDDING_KEY,
    base_url=AI_EMBEDDING_BASE_URL,
    check_embedding_ctx_length=False,  # DashScope 等非 OpenAI 网关可关掉 tiktoken 校验
)

engine = PGEngine.from_connection_string(url=get_postgres_url())

# 连接已有表:列名对齐 init-scripts 里的 travel_knowledge
vector_store = PGVectorStore.create_sync(
    engine=engine,
    table_name="travel_knowledge",
    embedding_service=embeddings,
    id_column="id",
    content_column="content",
    embedding_column="vector",
    metadata_json_column="metadata",
    metadata_columns=[],
    distance_strategy=DistanceStrategy.COSINE_DISTANCE,
)

PGVectorStore.create_sync(engine, embedding_service, table_name, ...) 接收连接引擎、Embedding 模型,以及表字段映射。它返回的 vector_store 会被入库和检索共同使用。

这里就不打算用小说了,直接准备一篇普通 Markdown 文档,来当作知识库(流程基本都是一样的,区别在于切片时,采用的 loader 可能不一样)。

md 复制代码
# 重庆三日游简要指南

重庆是一座依山傍水的山地城市,长江与嘉陵江在朝天门交汇,市区楼宇层叠、步道密集,夜景与火锅同样出名。第一次来建议按「市区地标 → 夜景江景 → 近郊轻松一日」安排三天,节奏适中,也方便用轨道交通串联。

**第一天:老城与江景。** 上午可从解放碑步行区起步,感受商圈与人流,随后前往洪崖洞。洪崖洞白天适合拍照看吊脚楼结构,晚上灯火通明,是重庆夜景的代表性机位之一;周边台阶多,穿防滑鞋更稳妥。下午继续到朝天门码头一带观两江交汇,若精力允许,可预约两江游船,傍晚开船能同时看到落日与夜景亮灯。晚上首选市区火锅,口味偏麻辣,点菜时可准备清汤或鸳鸯锅,并备好解辣饮品。

**第二天:山城步道与轻轨风景。** 上午可走山城步道或十八梯更新后的街区,体会依山而建的立体交通与居民生活气息。中午前后前往李子坝,观看轻轨穿楼而过的独特景观;人流较大时注意站在安全观景位置,勿翻越护栏。下午可安排磁器口古镇,逛手工艺与小吃,但周末非常拥挤,若讨厌人多可缩短停留,把时间留给茶馆歇脚。傍晚回到南岸南山一棵树或长江索道附近,再次俯瞰两江夜色;索道票价与排队情况会随节假日变化,建议当天提前在官方渠道确认。

**第三天:近郊轻松半日到一日。** 可选武隆天生三桥或仙女山(车程较长,适合起早),也可选北碚缙云山、偏自然的步道线路,减轻前两天的市区强度。若不想走远,可改去重庆动物园或市内博物馆做文化补充。返程前在机场或火车站预留充足安检与交通时间,重庆路面起伏大,高峰出租车与网约车耗时往往高于地图预估。

**交通与实用提示。** 市区地铁覆盖主要景点,1、2、3、6、环线较常用;洪崖洞、李子坝附近出站后仍需步行上下坡。春夏湿热多雨,秋冬可能有雾,随身带折叠伞和薄外套。火锅、小面、酸辣粉、烤鱼都常见,肠胃敏感者循序渐进,勿空腹猛辣。部分网红点位夜间人流与闪光灯较多,拍摄时留意脚下台阶。门票、游船、索道价格可能调整,出发前以景区或交通运营方最新公告为准。

入库流程保持最小化:读取文档、切片、补充元数据、生成向量并写入 PostgreSQL。

python 复制代码
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 每个切片最多 500 个字符,相邻切片保留 80 个字符重叠。
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=80,
)

# Document 用 page_content 保存正文,用 metadata 保存来源信息。
raw_document = Document(
    page_content=markdown_content,
    metadata={
        "source": "chongqing-travel.md",
        "title": "重庆三日游简要指南",
    },
)

# 将原始 Markdown 切成可分别生成向量的 Document 列表。
chunks = splitter.split_documents([raw_document])

chunk_size 控制每个片段的最大长度,chunk_overlap 保留相邻片段之间的重叠内容,减少答案刚好落在切分边界时无法召回的问题。切片完成后,为每个片段生成 UUID 并写入 PostgreSQL:

python 复制代码
import uuid

# travel_knowledge.id 是 UUID,因此为每个切片生成一个主键。
ids = [str(uuid.uuid4()) for _ in chunks]

# 内部完成:文本向量化 → 写入 content / metadata / vector。
vector_store.add_documents(chunks, ids=ids)

add_documents(documents, ids=...) 的第一个参数是待写入的 Document 列表,第二个参数可以传入对应的主键数组 ids。它内部会调用 Embedding 模型,再把 page_contentmetadata 和向量写入 travel_knowledge,应用层不需要手写 INSERT ... vector

数据插入成功。

最后做一次最小查询,确认刚才写入的数据可以被召回:

python 复制代码
# 参数一是用户问题,参数二是返回的 Top K。
results = vector_store.similarity_search_with_score(
    "洪崖洞更适合白天还是晚上?",
    k=4,
)

# API 返回 [(Document, score), ...],这里整理成后续 Graph 使用的结构。
documents = [
    {
        "score": 1.0 - float(score),  # 距离 → 相似度(越大越相似)
        "content": document.page_content,
        "source": document.metadata.get("source"),
    }
    for document, score in results
]

similarity_search_with_score(query, k) 会先把 query 转换成向量,再查询最相似的前 k 个片段,返回 (Document, score) 列表。Document 包含正文和元数据,score 在余弦策略下通常表示距离。

至此,向量存储已经从 Milvus 切换到 PostgreSQL,文档入库与最小查询也已经跑通。下面再进入 Agentic RAG 流程本身的重构。

LangGraph 流程编排

上一篇已经分别介绍了基础检索、查询路由、分步检索和联网补充,这里不再重复每个节点的内部实现,而是重点看它们如何合并成一条完整流程。

合并后,所有问题先经过 route_question:简单问题直接回答,复杂问题进入拆解与检索;retrieveplan_next 组成多轮检索循环;检索结束后由 evaluate 判断证据是否充分,不足时再进入 web_search。完整的 Graph 定义如下:

先定义各节点共享的状态。这里都是节点所需要传递的数据,并按路由、多跳检索、联网兜底和输出分组。

python 复制代码
from typing import Any, TypedDict


class GraphState(TypedDict, total=False):
    # --- 输入 / 路由 ---
    question: str  # 用户原问题
    strategy: str  # simple | complex

    # --- 多跳检索 ---
    sub_questions: list[str]  # 拆出的子问题列表
    next_sub_idx: int  # 下一轮要检索的子问题下标(也等于已检索轮数)
    documents: list[dict[str, Any]]  # 累计去重后的本地片段
    planned_next: str  # retrieve | evaluate

    # --- 联网兜底 ---
    web_context: str  # Bocha 补充文本
    evaluation: str  # 充分性评估 JSON:enough / web_query / ...

    # --- 输出 ---
    generation: str  # 最终回答

再把节点注册到 StateGraph,通过普通边确定固定顺序,通过条件边处理路由、检索循环和联网兜底:

python 复制代码
from langgraph.graph import END, START, StateGraph

graph = (
    StateGraph(GraphState)
    .add_node("route_question", route_question_node)
    .add_node("direct_answer", direct_answer_node)
    .add_node("decompose", decompose_question_node)
    .add_node("retrieve", retrieve_node)
    .add_node("plan_next", plan_next_step_node)
    .add_node("evaluate", evaluate_context_node)
    .add_node("web_search", web_search_node)
    .add_node("generate", generate_node)
    .add_edge(START, "route_question")
    .add_conditional_edges(
        "route_question",
        after_route,
        {
            "direct_answer": "direct_answer",
            "decompose": "decompose",
        },
    )
    .add_edge("decompose", "retrieve")
    .add_edge("retrieve", "plan_next")
    .add_conditional_edges(
        "plan_next",
        after_plan,
        {
            "retrieve": "retrieve",
            "evaluate": "evaluate",
        },
    )
    .add_conditional_edges(
        "evaluate",
        after_evaluate,
        {
            "generate": "generate",
            "web_search": "web_search",
        },
    )
    .add_edge("web_search", "evaluate")
    .add_edge("direct_answer", END)
    .add_edge("generate", END)
    .compile()
)

上图展示了重构后的完整执行链路。理解各节点之间的分支与循环,是读懂后续代码的关键。

路由判断

拿到用户问题后,先让 LLM 判断它是否需要查询知识库:普通问题标记为 simple,直接进入回答;涉及具体资料的问题标记为 complex,继续进入问题拆解。

python 复制代码
from typing import Literal

from pydantic import BaseModel


class RouteSchema(BaseModel):
    strategy: Literal["simple", "complex"]
    reason: str


def route_question_node(state: GraphState) -> dict:
    model = llm.with_structured_output(RouteSchema)
    prompt = f"""你是问答路由器。请判断用户问题是否需要检索本地旅游知识库。

规则:
- simple: 常识、闲聊、无需景点/行程/票价等具体事实即可回答。
- complex: 需要重庆行程、景点顺序、交通、餐饮、夜景机位、实用提示等具体事实。

用户问题:{state["question"]}
"""
    route = model.invoke(prompt)

    return {
        "strategy": route.strategy,
        "sub_questions": [],
        "next_sub_idx": 0,
        "documents": [],
        "planned_next": "",
        "web_context": "",
        "evaluation": "",
        "generation": "",
    }


def after_route(state: GraphState) -> str:
    """route 之后:simple → 直答;complex → 拆解。"""
    return "direct_answer" if state.get("strategy") == "simple" else "decompose"

直接回答

当路由结果为 simple 时,问题不需要知识库证据,直接交给模型回答即可。这个节点只读取原问题,并把模型结果写入 generation

python 复制代码
def direct_answer_node(state: GraphState) -> dict:
    response = llm.invoke(f"""
你是一个中文问答助手,请直接简洁回答问题。

问题:{state["question"]}
""")

    return {
        "generation": str(response.content),
    }

问题拆解

complex 问题可能只需要一次检索,也可能同时包含多个事实。这里先让模型把原问题拆成若干条可独立检索的子问题:只拆出一条就是单跳,拆出多条就会进入多跳检索。

python 复制代码
from pydantic import Field

class DecomposeSchema(BaseModel):
    sub_questions: list[str] = Field(min_length=1, max_length=6)
    reason: str

def decompose_question_node(state: GraphState) -> dict:
    model = llm.with_structured_output(DecomposeSchema)
    result = model.invoke(f"""
你是旅游知识库的子问题拆解器。

用户问题:{state["question"]}

要求:
1. 每条子问题都能独立用于向量检索;
2. 禁止使用"它、上述、那里"等指代;
3. 单跳问题输出 1 条,复杂问题最多输出 6 条。
""")

    sub_questions = [
        question.strip()
        for question in result.sub_questions
        if question and question.strip()
    ]

    return {
        "sub_questions": sub_questions,
        "next_sub_idx": 0,
    }

向量检索

检索节点每次只处理一条子问题。next_sub_idx 是当前检索游标:读取对应子问题,查询 PostgreSQL,再把本轮结果与历史结果合并。

python 复制代码
TOP_K = 4

def retrieve_node(state: GraphState) -> dict:
    index = state.get("next_sub_idx") or 0
    query = (state.get("sub_questions") or [])[index]

    if not query:
        raise RuntimeError(f"没有可检索的子问题:{index}")

    # 返回 [(Document, score), ...]。
    results = vector_store.similarity_search_with_score(query, k=TOP_K)

    new_documents = [
        {
            "id": document.id,
            "score": 1.0 - float(score),
            "content": document.page_content,
            "title": (document.metadata or {}).get("title"),
            "source": (document.metadata or {}).get("source"),
        }
        for document, score in results
    ]

    # 多轮结果按 id 去重;同一片段保留更高分数。
    document_map: dict[str, dict] = {}
    for document in [*(state.get("documents") or []), *new_documents]:
        previous = document_map.get(document["id"])
        if previous is None or document["score"] > previous["score"]:
            document_map[document["id"]] = document

    return {
        "documents": sorted(
            document_map.values(),
            key=lambda item: item["score"],
            reverse=True,
        ),
        "next_sub_idx": index + 1,
    }

规划下一步

一次检索结束后,不应该无条件查询所有子问题。规划节点会结合原问题、剩余子问题和当前证据,决定继续 retrieve,还是进入 evaluate

模型可以参与判断,但程序仍要保留确定性边界:没有剩余子问题,或者达到最大检索次数时,必须停止检索。

python 复制代码
MAX_RETRIEVALS = 6


class NextStepSchema(BaseModel):
    next_action: Literal["retrieve", "evaluate"]
    reason: str


def plan_next_step_node(state: GraphState) -> dict:
    next_index = state.get("next_sub_idx") or 0
    remaining = len(state.get("sub_questions") or []) - next_index

    # 只给规划器一份简短证据摘要。
    context = "\n".join(
        f"[{i + 1}] {document['content'][:160]}"
        for i, document in enumerate((state.get("documents") or [])[:5])
    )

    model = llm.with_structured_output(NextStepSchema)
    result = model.invoke(f"""
你是多跳 RAG 规划器。

原问题:{state["question"]}
剩余子问题:{remaining}
当前证据:
{context or "(暂无证据)"}

证据不足且仍有子问题时返回 retrieve;
否则返回 evaluate。
""")

    # 程序兜底,防止模型让检索无限循环。
    planned_next = result.next_action
    if remaining <= 0 or next_index >= MAX_RETRIEVALS:
        planned_next = "evaluate"

    return {"planned_next": planned_next}


def after_plan(state: GraphState) -> str:
    return "retrieve" if state.get("planned_next") == "retrieve" else "evaluate"

证据评估

规划结束后,需要进一步判断当前资料能否回答原问题。评估节点不生成答案,只输出结构化结论:证据是否足够、缺失什么,以及本地资料不足时应该搜索什么。

python 复制代码
import json


class EvaluateSchema(BaseModel):
    enough: bool
    missing: list[str] = Field(default_factory=list, max_length=6)
    reason: str
    web_query: str | None = None


def evaluate_context_node(state: GraphState) -> dict:
    local_context = "\n\n".join(
        f"""
[片段 {index + 1}]
来源:{document.get("title")}
内容:{document.get("content")}"""
        for index, document in enumerate(state.get("documents") or [])
    )

    has_web_context = bool((state.get("web_context") or "").strip())

    model = llm.with_structured_output(EvaluateSchema)
    result = model.invoke(f"""
你是信息充分性评估器。

用户问题:{state["question"]}

本地资料:
{local_context or "(空)"}

{f"联网资料:{chr(10)}{state.get('web_context')}" if has_web_context else ""}

判断当前资料是否足以回答问题;
如果不足,同时给出 web_query。
""")

    return {
        # 后面解析该 JSON,判断进行哪个节点
        "evaluation": json.dumps(result.model_dump(), ensure_ascii=False),
    }

联网补充

如果本地资料不足,使用评估节点生成的 web_query 调用博查搜索;如果没有生成查询词,则退回用户原问题。联网结果保留标题、URL 和摘要,后面生成答案时才能提供可核对的来源。

python 复制代码
import httpx

from src.config import BOCHA_API_KEY


def web_search_node(state: GraphState) -> dict:
    evaluation = json.loads(state.get("evaluation") or "{}")
    query = (evaluation.get("web_query") or "").strip() or state["question"]

    response = httpx.post(
        "https://api.bochaai.com/v1/web-search",
        headers={
            "Authorization": f"Bearer {BOCHA_API_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "query": query,
            "count": 6,
            "summary": True,
            "freshness": "noLimit",
        },
        timeout=30.0,
    )

    if response.status_code >= 400:
        raise RuntimeError(f"联网搜索失败:{response.status_code}")

    result = response.json()
    pages = (((result.get("data") or {}).get("webPages") or {}).get("value")) or []

    web_context = "\n\n".join(
        f"""
[联网结果 {index + 1}]
网页标题:{page.get("name")}
来源链接:{page.get("url")}
内容摘要:{page.get("summary")}"""
        for index, page in enumerate(pages)
    )

    return {"web_context": web_context}

搜索结束后,流程会重新进入 evaluate。如果已经存在 web_context,无论二次评估结果如何都进入生成,避免模型因为始终认为资料不足而反复联网。

python 复制代码
def after_evaluate(state: GraphState) -> str:
    if (state.get("web_context") or "").strip():
        return "generate"

    evaluation = json.loads(state.get("evaluation") or "{}")
    return "generate" if evaluation.get("enough") else "web_search"

最终生成

最后把本地检索结果和可选的联网结果合并成上下文,再让模型回答用户最初的问题。这里强调证据边界:上下文不足时要明确说明,不能脱离资料编造。

python 复制代码
def generate_node(state: GraphState) -> dict:
    local_context = "\n\n".join(
        f"""
[片段 {index + 1}]
来源:{document.get("title")}
内容:{document.get("content")}"""
        for index, document in enumerate(state.get("documents") or [])
    )

    context = "\n\n===== 联网补充 =====\n\n".join(
        part for part in [local_context, state.get("web_context")] if part
    )

    response = llm.invoke(f"""
请优先依据下面的资料回答,不要编造。

{context or "(没有可用资料)"}

用户问题:{state["question"]}

资料不足时,请明确说明无法确认;
使用联网信息时,请保留引用编号和 URL。
""")

    return {
        "generation": str(response.content),
    }

至此,一条完整的 Agentic RAG 链路就串起来了:先路由,再根据问题复杂度执行单跳或多跳检索;检索结束后评估证据,本地资料不足时联网补充,最后统一生成答案。

运行与流程验证

整条流程的运行入口,主要完成了:选择测试问题、连接检索服务,以及为 Graph 提供初始状态。

python 复制代码
def main() -> None:
    demo_questions = [
        "1+1等于几?",
        "洪崖洞更适合白天还是晚上?",
        "重庆三日游第一天和第二天怎么安排?李子坝和洪崖洞分别适合什么时候去?",
        "重庆两江游船现在怎么网上购票?官网入口是什么?",
    ]

    # 修改下标即可切换测试场景。
    question = demo_questions[2]

    engine = None
    try:
        engine, vector_store = connect_pg_vector(embeddings)

        graph.invoke(
            {
                "question": question,
                "strategy": "",
                "sub_questions": [],
                "next_sub_idx": 0,
                "retrieval_candidates": [],
                "documents": [],
                "planned_next": "",
                "web_context": "",
                "evaluation": "",
                "generation": "",
            }
        )
    finally:
        close_pg_vector(engine)

graph.invoke 接收用户问题和 Graph 的初始状态,然后从 START 开始驱动节点执行。finally 则确保流程成功或异常时都能释放 PostgreSQL 连接。

四个问题分别覆盖不同流程:

  • 直接回答1+1等于几? 不依赖知识库,应经过 route_question → direct_answer
  • 单跳检索:洪崖洞问题只涉及一个明确知识点,通常只完成一轮检索。
  • 多跳检索 :三日游问题涉及多天安排和多个景点,需要拆成多个子问题,并多次经过 retrieve → plan_next
  • 联网兜底 :游船购票入口属于可能变化的实时信息,本地资料不足时应进入 web_search

可以自己动手试一下哟。

pgvector + ES 混合检索

在该篇 Elasticsearch:给 RAG 补上关键词检索 中已经提到,Agent RAG 不能只依赖向量检索。向量检索擅长理解语义,但面对景点名称、线路编号等专有名词时,关键词检索往往更加准确。因此继续拓展前面的 Agentic RAG:由 PostgreSQL + pgvector 负责向量召回,再引入 Elasticsearch 补充 BM25 关键词召回,最终形成 pgvector + BM25 → ID 去重 → Rerank 的混合检索链路。

所以,现在的整体流程是这样的:

接下来,一步一步的实现。

首先要补充关键词检索,首先通过 Docker Compose 启动集成 IK 分词插件的 Elasticsearch 服务,以提升中文关键词的分词与匹配效果。

先安装 Elasticsearch 官方客户端:

bash 复制代码
# 跟 docker 镜像大版本保持一致
uv add "elasticsearch>=8.17.0,<9.0.0"

客户端只需要读取地址和索引名称:

python 复制代码
from elasticsearch import Elasticsearch

from src.config import ELASTICSEARCH_URL

def create_elasticsearch_client() -> Elasticsearch:
    return Elasticsearch(ELASTICSEARCH_URL)

Elasticsearch 索引保存文档内容和必要的元数据,其中 contenttitle 使用 IK 分词,供 BM25 关键词检索使用:

python 复制代码
client.indices.create(
    index=index,
    mappings={
        "properties": {
            "content": {
                "type": "text",
                "analyzer": "ik_max_word",
                "search_analyzer": "ik_smart",
            },
            "title": {
                "type": "text",
                "analyzer": "ik_max_word",
                "search_analyzer": "ik_smart",
            },
            "source": {"type": "keyword"},
        }
    },
)

双写 PG 与 ES

增加 Elasticsearch 后,插入流程也需要调整。每个 chunk 仍然需要同时写入两套存储,但两者的职责不同:PostgreSQL 保存文档、元数据和 embedding 向量,Elasticsearch 只保存文档和元数据,用于 BM25 关键词检索。

两套存储使用相同的 UUID 关联同一个 chunk;embedding 只生成一次,并且只写入 PostgreSQL:

python 复制代码
from elasticsearch.helpers import bulk

from src.config import ELASTICSEARCH_INDEX


def upsert_chunks(vector_store, elasticsearch_client, documents):
    ids = [str(uuid.uuid4()) for _ in documents]
    texts = [document.page_content for document in documents]

    vectors = embeddings.embed_documents(texts)

    # PostgreSQL 保存知识数据和向量
    vector_store.add_embeddings(
        texts=texts,
        embeddings=vectors,
        metadatas=[document.metadata for document in documents],
        ids=ids,
    )

    # 组织 Elasticsearch Bulk API 所需的文档动作。
    actions = [
        {
            "_op_type": "index",
            "_index": ELASTICSEARCH_INDEX,
            "_id": doc_id,
            "content": document.page_content,
            "title": document.metadata.get("title"),
            "source": document.metadata.get("source"),
        }
        for document, doc_id in zip(documents, ids, strict=True)
    ]

    # Elasticsearch 只保存文本和元数据,供 BM25 检索。
    bulk(elasticsearch_client, actions, refresh=True)

数据插入成功

需要注意,这种方式属于应用层双写,PostgreSQL 和 Elasticsearch 之间不存在跨数据库事务。当前项目是流程演示,因此采用同步双写;实际生产环境通常会以 PostgreSQL 为主存储,再通过消息队列或 CDC 异步同步到 Elasticsearch。

并行召回与去重

入库完成后,retrieve 节点并行查询两套存储:

  • PostgreSQL + pgvector:通过 embedding 相似度寻找语义相关的文档;
  • Elasticsearch + BM25:匹配景点名称、线路编号等关键词。
python 复制代码
from concurrent.futures import ThreadPoolExecutor

from src.config import ELASTICSEARCH_INDEX

with ThreadPoolExecutor(max_workers=2) as pool:
    # PostgreSQL:向量相似度检索
    vector_future = pool.submit(
        vector_store.similarity_search_with_score,
        query,
        candidate_k,
    )
    # Elasticsearch:BM25 关键词检索
    keyword_future = pool.submit(
        elasticsearch_client.search,
        index=ELASTICSEARCH_INDEX,
        size=candidate_k,
        query={
            "multi_match": {
                "query": query,
                "fields": ["title^2", "content"],
            }
        },
    )
    vector_results = vector_future.result()
    keyword_response = keyword_future.result()

vector_documents = [
    {
        "id": document.id,
        "content": document.page_content,
        "source": (document.metadata or {}).get("source"),
        "score": 1.0 - float(score),
    }
    for document, score in vector_results
]

keyword_documents = [
    {
        "id": hit["_id"],
        "content": hit["_source"]["content"],
        "source": hit["_source"]["source"],
        "score": hit.get("_score") or 0,
    }
    for hit in keyword_response["hits"]["hits"]
]

同时并行执行向量检索和关键词检索

(可以发现,两者 score 的维度是不一样,混合之后是不能根据 score 进行排序)

接着,通过文档 ID 合并去重:

python 复制代码
unique_documents: dict[str, dict] = {}

for document in [*vector_documents, *keyword_documents]:
    doc_id = document.get("id")
    if not doc_id or doc_id in unique_documents:
        continue
    unique_documents[doc_id] = document

retrieval_candidates = list(unique_documents.values())

这里使用 dict 记录已经出现过的文档 ID,同一个片段即使被 pgvector 和 BM25 同时召回,也只会保留一次。当前示例每路只召回少量候选,去重后可以直接交给 Rerank; 如果知识库规模较大、召回候选较多,也可以在 Rerank 前增加 RRF(Reciprocal Rank Fusion,倒数排名融合)粗排,根据文档在多路结果中的排名筛选出更小的候选集,从而降低 Rerank 的调用成本。

Rerank 精排

ID 去重只能消除重复文档,无法判断候选内容与用户问题的真实相关程度。因此流程中继续增加 rerank 节点,把去重后的候选文档交给专用重排模型进行精排。

Rerank 与 Embedding 的职责不同:Embedding 模型负责把文本转换成向量,用于扩大召回范围;Rerank 模型则同时接收用户问题和候选文档,逐一计算相关性分数并重新排序。它相当于召回后的精细筛选器,可以过滤噪声,把更有可能回答问题的文档排在前面。

这里采用阿里云百炼提供的 qwen3-rerank 模型:

配置好环境变量之后,接下来将 Rerank API 封装成统一的 rerank_documents 函数:

python 复制代码
import httpx

from src.config import AI_RERANK_KEY, AI_RERANK_MODEL, AI_RERANK_URL


def rerank_documents(*, query: str, documents: list[dict], top_k: int) -> list[dict]:
    if not documents:
        return []

    response = httpx.post(
        AI_RERANK_URL,
        headers={
            "Authorization": f"Bearer {AI_RERANK_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "model": AI_RERANK_MODEL,
            "input": {
                "query": query,
                "documents": [document["content"] for document in documents],
            },
            "parameters": {
                "return_documents": False,
                "top_n": top_k,
            },
        },
        timeout=30.0,
    )

    result = response.json()
    if response.status_code >= 400:
        raise RuntimeError(f"DashScope Rerank 调用失败:{response.status_code}")

    # 根据 rerank 返回索引的结果,进行返回
    return [
        {
            **documents[item["index"]],
            "score": item["relevance_score"],
        }
        for item in result["output"]["results"]
    ]

LangGraph 中的 rerank_node 只负责调用这个接口,再把本轮精排结果合并到历史检索结果:

python 复制代码
def rerank_node(state: GraphState) -> dict:
    index = state.get("next_sub_idx") or 0
    query = (state.get("sub_questions") or [])[index]

    reranked_documents = rerank_documents(
        query=query,
        documents=state.get("retrieval_candidates") or [],
        top_k=TOP_K,
    )

    return {
        "documents": merge_unique_by_id(
            state.get("documents") or [],
            reranked_documents,
        ),
        "retrieval_candidates": [],
        "next_sub_idx": index + 1,
    }

引入混合检索后,图里的固定边会变成:retrieve → rerank → plan_nextretrieve 只负责并行召回并写出 retrieval_candidates,真正推进游标、合并历史证据的工作交给 rerank

到这里,本地检索不再是单纯的向量相似度搜索,而是由 PostgreSQL + pgvector 负责语义召回、Elasticsearch + BM25 负责关键词召回,按文档 ID 合并去重后,再通过 Rerank 模型筛选出与当前子问题最相关的片段。

后面的多跳规划、证据评估和联网兜底流程保持不变。

总结

整条 Agentic RAG 链路以 LangGraph 串联:查询路由、问题拆解、多跳检索、证据评估和联网兜底在同一张图中协作,根据问题复杂度与当前证据动态决定下一步。向量存储采用 PostgreSQL + pgvector,语义召回与 Elasticsearch 关键词检索并行,经 ID 去重后交给 Rerank 精排,形成 pgvector + ES → Rerank 的混合检索。

  • 流程层面:核心不是固定 retrieve → generate,而是简单问题直答,复杂问题拆解后多轮检索,本地证据不足时再联网补充。
  • 存储层面:PostgreSQL 保存文档、元数据和 embedding,承担语义召回;Elasticsearch 只保存文本与元数据,承担关键词召回,两者通过相同 UUID 关联同一 chunk。
  • 检索层面:向量检索擅长语义,ES 擅长专有名词与精确匹配;两路并行召回后按 ID 去重,候选量大时可在 Rerank 前增加 RRF 粗排。
  • 精排层面:去重只消除重复,Rerank 模型才判断「问题与文档是否真正相关」,把最可能回答问题的片段排到前面。
相关推荐
动力 continue1 小时前
Python 元类与异常类:类的“制造工厂”与“报错定制师”
开发语言·python·制造
147API1 小时前
Python批量生成蒸馏数据,并发、重试、幂等和断点续跑
开发语言·jvm·python
青 春 记 忆1 小时前
零基础入门python04:用注册校验理解字典、集合和条件判断
开发语言·vscode·python·python3.11
JaydenAI1 小时前
[基于OpenEvals的自动化评估-12]Agent执行轨迹评估[无LLM参与]
ai·langchain·agent·evaluation·openevals
kels88991 小时前
实战排坑:黄金实时API开发,XAUUSD Tick报文异常处理实践
开发语言·python·websocket·网络协议·信息可视化
FBI HackerHarry浩1 小时前
Pandas 库中用于分类变量独热编码(One-Hot Encoding)的函数 pd.get_dummies() 函数
开发语言·人工智能·python·pandas
高洁011 小时前
工信部教考中心证书-人工智能系列本系列
人工智能·python·深度学习·算法
生命涌现1 小时前
【生命涌现植物健康分析】在火山云Agent平台的使用教程
人工智能·计算机视觉·agent·多模态大模型·openclaw小龙虾技能
MomentYY1 小时前
RAG 评估:答错了,是检索的错还是生成的错?
人工智能·agent·ai编程