检索 + 大模型,让系统「开口回答」:基础 RAG 落地实测(M3)
系列 :城市管理 Agentic RAG ------ 从零搭建城市管理问答系统
本篇 :M3 · 基础 RAG(实测版)
一、M2 之后还差什么?
M2 做完,系统会「找」了:问「暴雨」能捞回 3 段相关文本。但捞回来的是碎片,不是人话:
text
气象局 预警等级.txt: 橙色:3小时内降雨量将达50毫米以上...
应急局 防汛应急响应预案.txt: Ⅲ级:暴雨红色预警,或城区内涝积水点超过 20 处...
市民要的是:「暴雨橙色预警怎么响应? 」------一句完整、有条理、带出处的回答。这就是 M3:检索增强生成(RAG)。
上一版 M3 文章是设计稿(当时代码还没写)。现在代码落地了,这篇是实测版:三个文件、一个闭环、两条真实验收问答,全部真实运行输出。
二、RAG 在干什么(大白话版)
把大模型想象成一个知识渊博但记性差的专家 。你直接问他,他可能凭印象瞎说(这就是「幻觉」)。RAG 的做法是:先查资料,把资料塞给他,让他照着资料答。
text
你的问题
│
▼
[检索] 从 Chroma 捞出最相关的 3 段资料(M2 已就绪)
│
▼
[拼接] 问题 + 资料 + 指令 = Prompt
│
▼
[生成] 大模型照着资料回答,标注引用来源
关键点:大模型只负责「组织语言」,不负责「回忆知识」------知识来自检索到的真实资料,从根上抑制幻觉。
三、落地:三个文件,一个闭环
1. retriever.py:把「能查」包装成「好查」
M2 的 db.query() 已经能查了,M3 包一层更语义化的接口,顺便把「来源标注」格式化好:
python
def retrieve(query: str, top_k: int = 3,
dept_filter: str | None = None) -> list[dict]:
"""检索与 query 最相关的文本块。"""
db = ChromaVectorDB()
return db.query(query, top_k=top_k, dept_filter=dept_filter)
def format_context(hits: list[dict]) -> str:
"""把检索结果拼成 Prompt 上下文,每段前标注来源。"""
blocks = []
for i, hit in enumerate(hits, 1):
meta = hit.get("metadata", {})
dept = meta.get("dept", "未知部门")
fname = meta.get("file", "未知文件")
blocks.append(f"[{i}] (来源:{dept}/{fname})\n{hit['content']}")
return "\n\n".join(blocks)
两个细节:
dept_filter参数是为 M4 的 Agentic 路由预留的通道------路由判断「这是气象问题」,检索就只查气象,省得拿民政的资料凑数。format_context给每段资料贴上「来源:部门/文件名」的标签,大模型回答时就能直接引用------这是让回答可溯源的关键一步。
2. generator.py:LLMClient,可替换的大模型客户端
大模型接入做成一个类,配置全部走 .env(项目 M0 就留好了位子):
python
class LLMClient:
def __init__(self, api_type: str = "deepseek"):
self.api_type = api_type # 可扩展: openai / qwen / claude
self.api_key = os.getenv("API_KEY", "").strip()
self.base_url = os.getenv("API_BASE_URL", "https://api.deepseek.com/v1")
self.model_name = os.getenv("MODEL_NAME", "deepseek-chat")
if not self.api_key:
raise ValueError("未配置 API_KEY:请复制 .env.example 为 .env 并填入 API Key")
def chat(self, prompt: str, temperature: float = 0.3,
max_tokens: int = 1024) -> str:
"""调用大模型补全对话。"""
from openai import OpenAI
client = OpenAI(api_key=self.api_key, base_url=self.base_url)
resp = client.chat.completions.create(
model=self.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
max_tokens=max_tokens,
)
return resp.choices[0].message.content.strip()
选型说明:
- 为什么 DeepSeek :走 OpenAI 兼容协议,
openai库改个base_url就能用;中文能力强、价格便宜,原型阶段首选。 - 为什么温度设 0.3:问答场景要「稳」不要「飘」,低温度减少发散。
- 为什么构造时就校验 key :
.env没配好,启动第一秒就报错,而不是跑十轮问答才在中间炸掉。
.env 长这样(API_KEY 填你自己的,gitignore 已忽略,不会提交):
text
API_KEY=sk-your-key-here
API_BASE_URL=https://api.deepseek.com/v1
MODEL_NAME=deepseek-chat
3. Prompt 模板:RAG 的效果一半在 Prompt
模板只有四行,两条硬约束:
text
你是城市应急分析助手。根据以下从各部门检索到的资料回答问题。
必须标注引用来源(部门+文件名)。信息不足请明确告知。
参考资料:
{context}
问题:{question}
- 必须标注引用来源------回答自带出处,可溯源、可核验,用户能自己点开原文核对
- 信息不足请明确告知------防止模型硬编造,这是 M5「答案反思」的前置习惯
4. cli.py:把链路串起来
bash
python -m src.cli
text
====================================================
城市应急 Agentic RAG · 基础问答(M3)
输入问题开始问答,输入 exit/退出 结束
====================================================
❓ 问题 >
输入 → 检索 → 拼 Prompt → 生成 → 打印(含引用),一个最简单的可用闭环。三条退出命令 exit / quit / 退出 随便用。
四、真实验收:两条问答,全部带来源
推进表 M3 的验收标准是两条跨部门问答,我们逐个实测。
验收 1:问民政问题
❓ 问题 > XX街道独居老人有多少?
检索侧命中(真实输出):
text
[0.7603] 民政局/弱势群体统计.txt
[0.6438] 民政局/养老服务设施.txt
[0.5048] 民政局/低保救助政策.txt
前 3 条全来自民政局,没有一条串到别的部门。生成侧回答(节选):
text
💬 回答:
XX街道独居老人为 156人(来源:民政局/弱势群体统计.txt)。
若您需要其他街道或更近期的数据,当前资料不足,建议补充更新信息。
📎 引用来源:
- 民政局/弱势群体统计.txt
- 民政局/养老服务设施.txt
- 民政局/低保救助政策.txt
✅ 返回民政数据 + 来源,验收通过。
验收 2:问气象/应急问题
❓ 问题 > 暴雨橙色预警怎么响应?
检索侧命中(真实输出):
text
[0.7419] 气象局/预警等级.txt
[0.7125] 应急局/防汛应急响应预案.txt
[0.6494] 气象局/预警信号发布规范.txt
生成侧回答(节选):
text
💬 回答:
根据现有资料,暴雨橙色预警的响应要求如下:
1. 响应启动条件:气象部门发布暴雨橙色预警时,启动Ⅳ级应急响应
2. 具体响应动作:
- 各成员单位到岗值班
- 抢险队伍集结待命
- 避难场所开放
- 危险区域群众转移(橙色及以上强制转移)
3. 预警发布渠道:市气象台官方微博、微信公众号、应急广播、短信平台
(依据[1]气象局/预警等级.txt、[2]应急局/防汛应急响应预案.txt、
[3]气象局/预警信号发布规范.txt)
信息不足说明:资料未明确橙色预警对应的具体降雨量数值、强制转移的
具体人数范围,需结合原始文件核对。
📎 引用来源:
- 气象局/预警等级.txt
- 应急局/防汛应急响应预案.txt
- 气象局/预警信号发布规范.txt
✅ 返回气象数据 + 来源,验收通过。
注意回答末尾的「信息不足说明 」------这是 Prompt 硬约束在起作用:模型主动承认资料里没写清楚的东西,而不是编一个数字糊弄你。「不编」比「答对」更难,RAG 的价值就在这里。
五、踩过的坑
坑 1:API Key 是拦路虎
.env.example 从 M0 就有,但 API_KEY 一直是占位符。M3 开工第一件事就是填真 key------没有它,生成环节整个跑不起来。教训:M0 就把 key 申请好,别拖到里程碑当天。
坑 2:模型偶尔「过度谨慎」
验收 1 第一次跑,模型开头先说「无法直接查询到 XX 街道独居老人的具体人数」,绕了一大圈最后才给出 156 人。数据明明完全匹配,它却先自我怀疑。这是大模型「过度对齐」的典型表现------prompt 里把「必须基于资料直接回答」写得再明确一点,或者把检索分数(0.76 已经很高)作为置信度提示给它,能缓解。这个问题 M5「答案反思」阶段会系统处理。
坑 3:别小看格式化的细节
format_context 里给每段加 [1] (来源:部门/文件名) 前缀,看起来只是排版,实际是让模型回答时能指着编号说「依据1」------引用格式立刻规范了。Prompt 工程的胜负手往往在这些细节里。
六、为 M4/M5 铺的路
M3 是「地基」模块,几个设计决策直接服务后面的 Agentic 阶段:
retriever的dept_filter参数 → M4 路由的落点:路由说「这是气象问题」,检索就只查气象- Prompt 里「标注来源」的硬约束 → M5 反思时对照核查的依据:回答和引用逐条比对,编没编一目了然
LLMClient(api_type=...)可替换 → 换模型不改业务代码,二期接国产大模型零成本
七、下一步
系统现在能「查」能「答」了,但有个尴尬:问「暴雨橙色预警怎么响应」,它把气象、民政、热线的资料全捞出来一起答------检索是盲目的,没有「意图」概念。下一步 M4:Agentic 路由,让系统先判断「这是个气象问题」,再决定查哪个部门。
下一篇 :[M4 Agentic 路由:让每个问题找到对的部门](#M4 Agentic 路由:让每个问题找到对的部门)
上一篇 :[M2 切分与向量化:让机器「看懂」城市知识](#M2 切分与向量化:让机器「看懂」城市知识)
系列目录 :[城市管理 Agentic RAG ------ 从零搭建城市管理问答系统](#城市管理 Agentic RAG —— 从零搭建城市管理问答系统)
📚 想了解更专业的内容? 本文是项目实战记录。如果你对 RAG 的原理、Prompt 工程技巧、大模型 API 接入的完整方案感兴趣,欢迎访问我的 CSDN 专栏 喵本喵叁肆的 Agentic RAG 实战专栏 阅读完整的技术博客系列(含可运行代码、架构图与验收标准)。