
在前面的两篇文章中:
第一篇围绕 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_embeddings和similarity_search_with_scoreAPI。真正负责向量存储与检索的仍然是 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_content、metadata 和向量写入 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:简单问题直接回答,复杂问题进入拆解与检索;retrieve 和 plan_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 索引保存文档内容和必要的元数据,其中 content、title 使用 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_next。retrieve 只负责并行召回并写出 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 模型才判断「问题与文档是否真正相关」,把最可能回答问题的片段排到前面。