storemind
by wenjinglabs
README.md
# StoreMind · 门店运营知识库 Agent
面向连锁酒吧 / 餐饮门店的 SOP 知识库问答系统。店员问「客人说酒有异味怎么办」,店长问「上周天河店卖了多少」,都能答。
不是纯 RAG 问答,两个原因:
1. **规则和事实是分开的**——规则在文档里,事实在订单库里。所以系统先做意图路由,再决定查知识库、查数据库,还是两路并发。
2. **检索结果不一定够用**——所以模型在拿到资料后要先自检,不够就改写检索词重查。
```
┌─ retrieve_guard ─┐
店员提问 → route ────────┤ ├─ merge → judge ─┬→ answer(带引用)
└─ data_guard ─────┘ │ ▲
│ └─ rewrite → retrieve_again
└──── 判充足
```
两个分支同一 superstep 并行执行,merge 等两路都完成才放行。
**judge → rewrite → retrieve_again → judge 这个环是本项目的 Agent 层**——
图里唯一的条件边,也是唯一一处「走哪条路由模型运行时决定」的地方。
编排用 LangGraph,会话状态走 PostgreSQL checkpointer,`thread_id` 一给就有多轮上下文。
**同一套逻辑有两套实现**:`agent/graph.py`(LangGraph,图里的环)和 `agent/pipeline.py`(手写状态机,while 循环)。
`scripts/compare_orchestration.py` 会验证两条路径输出一致——6/6 通过。
**这比「我用了 LangGraph」有说服力得多**:同一个自检循环,手写版是 12 行 while,
图版是两条边,而它们的输出完全一致。
## 为什么做这个
多门店 SaaS 的共性痛点:运营知识散在群聊、SOP 文档、活动规则里。新人上手慢,同一个问题不同店长答得不一样。而客人问「我储值送的 100 能不能在另一家店花」这种问题,答案一半在制度文档里、一半在会员系统里——单靠文档问答答不准。
## 快速开始
### 方式一:本地 Python + 宿主机 ollama(不使用 Docker)
需要一块 GPU(8GB 显存即可)和宿主机 ollama:
```bash
# 1. 装 ollama 并起服务
scoop install ollama # Windows;macOS/Linux 用官方 install.sh
ollama serve # 默认 127.0.0.1:11434
ollama pull bge-m3 # 1.2GB,嵌入
ollama pull qwen2.5:7b # 4.7GB,生成
# 2. 装项目依赖
python -m venv .venv
.venv\Scripts\activate # Windows; macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
# 3. 准备数据
python scripts/seed_ops_db.py # 生成门店经营数据(4 店 / 60 天 / 6561 单)
python scripts/build_index.py --rebuild # 建知识库索引(bge-m3,1024 维,约 2.5 秒)
python scripts/smoke_test.py # 端到端冒烟,5 条用例
# 4. 起服务
uvicorn app.main:app --reload --port 8000
```
打开 **`http://127.0.0.1:8000/`** 是可以直接对话的页面。或者 `http://127.0.0.1:8000/docs` 看接口。
> 当前默认部署是下面的 Docker(模型也在容器里);宿主机 ollama 计划任务已禁用。
> 要走这条本地路径,需要手动起 `ollama serve`,并把 `.env` 的 `SM_OLLAMA_BASE` 改回 11434。
### 方式二:本地 Python(复用 Docker 里的 PostgreSQL 和 ollama)
运行时只有 PostgreSQL 一个存储后端。先用 compose 起数据库和模型(不启动 app/mcp,避免占 8000):
```bash
docker compose -f deploy/docker-compose.yml up -d postgres ollama ollama-import ollama-pull
pip install -r requirements.txt
python scripts/seed_ops_db.py
python scripts/build_index.py --rebuild
uvicorn app.main:app --reload --port 8000
```
`.env` 里的 `SM_OLLAMA_BASE` 指向 `http://127.0.0.1:11435`(容器的宿主映射)。
不想用模型时,把 `SM_EMBED_BACKEND=hashing`、`SM_LLM_BACKEND=none`:
仍然需要 PostgreSQL(compose 提供),但不需要 GPU 和 ollama。
### 方式三:Docker(推荐,模型也在里面)
```bash
docker compose -f deploy/docker-compose.yml up -d --build
# 网页 http://127.0.0.1:8000/ · MCP http://127.0.0.1:8765/mcp
python scripts/verify_docker.py --expect-embed ollama --expect-llm ollama # 10/10
```
默认起 ollama 并导入/补齐模型(`ollama-import` / `ollama-pull`);首次需要 Docker Hub 网络。
**存储固定 PostgreSQL + pgvector**(compose 会起 postgres 服务并等它健康);没有 SQLite 回退路径。
无模型模式(不调用 ollama,但 PG 仍要起):
```powershell
$env:SM_EMBED_BACKEND="hashing"; $env:SM_EMBED_DIM="512"; $env:SM_LLM_BACKEND="none"
docker compose -f deploy/docker-compose.yml up -d --force-recreate --wait
python scripts/verify_docker.py --expect-embed hashing --expect-llm none
```
```bash
# bash
SM_EMBED_BACKEND=hashing SM_EMBED_DIM=512 SM_LLM_BACKEND=none \
docker compose -f deploy/docker-compose.yml up -d --force-recreate --wait
```
本机实测(Docker 29.9.2,镜像 334MB,`scripts/verify_docker.py` 可复现):
| 配置 | /health | 规则题 | 数据题 | MCP |
|---|---|---|---|---|
| hashing + none(PG) | db=postgres docs=8 chunks=45 | 引用 5 段,抽取式 477 字 | `store_sales` revenue=51115.0 | 10/10 |
| ollama + qwen2.5:7b(PG) | db=postgres docs=8 chunks=45 | 引用 5 段,生成 75 字,首次 0.93s | `store_sales` revenue=51115.0 | 10/10 |
两个实现细节:
- 业务数据、工作流、checkpoint 全在 PG;容器只读挂载 `corpus/`,不挂宿主机 `data/`。
- `mcp` 服务等 `app` 健康后再启动,避免两个进程同时初始化数据库。
> Docker Desktop 需要 WSL2,且 `bcdedit` 的 `hypervisorlaunchtype` 必须是 `auto`。
> 本机安装时踩过 `no virtualization available`,记录在 HANDOVER 6.11。
### 把 Ollama 也放进 Docker(默认)
主 compose 已经包含模型服务,`docker compose up -d` 会按顺序:
1. `ollama-import`:从宿主机 `~/.ollama/models` 导入已有模型到命名卷(一次性,已有 `.imported` 标记则跳过)
2. `ollama`:启动 Ollama,容器内 `11434`,宿主映射 `11435` 供本地脚本调试
3. `ollama-pull`:补齐缺失的 `bge-m3` / `qwen2.5:7b`;拉取失败不阻塞整栈
4. `app` / `mcp`:使用 `http://ollama:11434`,建索引并启动
Docker 的服务名/容器名只允许 ASCII(`[a-zA-Z0-9][a-zA-Z0-9_.-]`),所以底层服务名仍是
`ollama` / `ollama-import` / `ollama-pull` / `app` / `mcp` / `postgres`;
每个容器加了中文 label,配一个中文快捷脚本:
```powershell
.\scripts\docker.ps1 启动 # 起全部(含模型;缺失时自动拉)
.\scripts\docker.ps1 重新构建 # 改了代码后重建 app 镜像
.\scripts\docker.ps1 状态 # 中文 label:应用 / 接口 / 数据库 / 模型 / 模型导入 / 模型拉取
.\scripts\docker.ps1 日志 应用
.\scripts\docker.ps1 停止
```
宿主机上的 ollama 计划任务已禁用;本地跑评估脚本时 `.env` 指向 `127.0.0.1:11435`(容器映射端口)。
需要无模型模式时,用 `SM_EMBED_BACKEND=hashing SM_EMBED_DIM=512 SM_LLM_BACKEND=none` 重建 app。
### 命令行
```bash
python scripts/search_cli.py "会员卡能跨店用吗"
python scripts/search_cli.py --batch # 8 条示例
python scripts/diagnose.py "满减和折扣券能一起用吗" # 分离看两路召回
python scripts/diagnose_dual.py # 两路命中率对比
python scripts/weight_sweep.py # 向量权重对照实验
python scripts/eval.py # 跑金标集,出 recall@k
python scripts/compare_orchestration.py # 手写编排 vs LangGraph 等价性 + 多轮演示
python scripts/verify_e2e.py # 真模型端到端验证(11 条用例)
python scripts/mcp_smoke_test.py # MCP 协议冒烟(stdio,14 项检查)
python scripts/workflow_test.py # 工作流:提案/确认/越权/幂等/审计(9/9)
python scripts/verify_docker.py # Docker 部署验证(app + MCP + 工作流,10 项)
```
多轮会话(同一 `thread_id` 共享上下文):
```bash
curl -X POST http://127.0.0.1:8000/chat \
-H "Content-Type: application/json" \
-d "{\"query\":\"会员卡能跨店用吗\",\"thread_id\":\"u1\"}"
curl -X POST http://127.0.0.1:8000/chat \
-H "Content-Type: application/json" \
-d "{\"query\":\"那退卡呢\",\"thread_id\":\"u1\"}"
```
## MCP server(自己实现协议,不用 SDK)
把知识库和经营数据暴露成标准 MCP 工具,任何 MCP 客户端(Claude Desktop / Cursor / Claude Code)都能接。
**协议是自己写的**:JSON-RPC 2.0 帧、`initialize` 握手、能力协商、`tools/list` / `tools/call`、
`resources/list` / `resources/read`,只用标准库,没有装 `mcp` SDK。
```bash
# stdio:MCP 的标准传输,客户端拉起子进程用这个
python scripts/mcp_server.py --stdio
# HTTP:方便 curl 和容器部署
python scripts/mcp_server.py --http 8765
curl -s http://127.0.0.1:8765/health
```
| 工具 | 用途 |
|---|---|
| `search_knowledge` | 混合检索知识库,返回章节路径、分值和原文片段 |
| `list_knowledge_docs` | 列出文档与切片数 |
| `ask_storemind` | 完整 Agent:路由 + 检索/查库 + 自检重查,返回答案、引用、执行轨迹 |
| `list_stores` | 列出全部门店 |
| `store_sales` / `top_items` / `redemption_stats` / `inventory_alert` | 白名单经营数据查询,参数校验后执行预编译 SQL |
| `propose_ticket` / `propose_restock` | 生成客诉工单 / 补货提案(只提案,不落库) |
| `list_pending_actions` / `confirm_action` / `reject_action` | 查看待办、确认 / 拒绝,带角色校验与审计 |
语料同时以 `storemind://corpus/{doc_id}` 资源暴露,客户端可以直接挂进上下文,不用先调工具。
客户端配置见 `mcp.example.json`(把 `<项目绝对路径>` 换成实际路径):
```json
{
"mcpServers": {
"storemind": {
"command": "<项目绝对路径>/.venv/Scripts/python.exe",
"args": ["<项目绝对路径>/scripts/mcp_server.py", "--stdio"]
}
}
}
```
协议级验证(握手顺序、通知无响应、三类错误码、真实工具调用、工作流):
```bash
python scripts/mcp_smoke_test.py # 14/14
python scripts/mcp_smoke_test.py --zero-dep # 14/14,hashing + none 也能跑
```
两个实现细节:stdio 的 stdout 只允许协议消息,日志全部走 stderr——混一行 print 客户端就解析失败;
工具不存在返回 JSON-RPC `-32602`,工具跑通但查不到数据返回 `isError: true`,这两种失败语义不一样。
`tools/call` 同时返回给人读的 `content` 和给客户端机器读的 `structuredContent`。
## 可执行工作流(提案 → 确认 → 审计)
写操作**不让模型直接执行**,统一走受控闭环:
```
propose_ticket / propose_restock
→ pending 提案(不产生业务副作用)
→ 前端确认卡片 / POST /actions/{id}/confirm
→ 角色校验(补货需 manager)
→ 写 tickets / restock_requests + audit_log
```
- **幂等**:同一条提案重复确认只返回第一次结果;`idempotency_key` 防止重复提案
- **权限**:店员可确认客诉工单;补货必须店长确认,越权返回 403 并写审计
- **审计**:`proposed` / `confirmed` / `rejected` / `permission_denied` 全部留痕
- **演示**:打开 `http://127.0.0.1:8000/#wf`,或在页面右上角点「操作提案」
- **测试**:`python scripts/workflow_test.py` → 9/9(提案、确认、越权、幂等、拒绝、校验)
- Docker 部署验证包含 2 条工作流用例,总计 10/10
## 网页里能看到什么
页面刻意做成「把黑箱拆开给你看」,而不只是聊天框。每条回答下面有三层可展开信息:
| 区块 | 内容 | 为什么要有 |
|---|---|---|
| **意图徽标 + 路由理由** | `规则检索` / `数据查询` / `两路并发`,以及判定依据 | 让人一眼看到「这个问题为什么走了 SQL 而不是文档」 |
| **执行轨迹** | 每一步的名称、耗时、判定细节(`route 1ms` → `retrieve 21ms` → `generate 674ms`) | 出问题时能定位到具体环节,不是黑箱 |
| **引用来源** | 命中的章节路径、文档名、相关性分数,点开看原文片段 | 企业知识库第一诉求是「能核对」——答案必须对得上出处 |
答案里的角标可点击,两种编号对应两种来源:
- `[1]` 指向**文档片段**(点开高亮引用卡片,显示原文)
- `[D1]` 指向**经营数据行**(点开高亮工具调用表格的对应行)
工具栏显示 `store_sales` 这类工具调用及其返回的真实聚合数据。
右上角「操作提案」打开受控写操作面板:生成客诉工单/补货提案,确认后落库并写审计;补货需要店长角色。
## 换成生产配置
默认值已经是「本地 ollama + bge-m3 + qwen2.5:7b」,下面列的是换其他组合的方式。
```bash
# 嵌入走本地 ollama 的 bge-m3(默认)
set SM_EMBED_BACKEND=ollama
set SM_OLLAMA_BASE=http://127.0.0.1:11434
set SM_EMBED_MODEL=bge-m3
# 生成走任意 OpenAI 兼容接口(DeepSeek / Qwen / vLLM 都行)
set SM_LLM_BACKEND=openai
set SM_OPENAI_BASE=https://api.deepseek.com/v1
set SM_OPENAI_KEY=sk-xxx
set SM_OPENAI_MODEL=deepseek-chat
python scripts/build_index.py
```
**换嵌入后端后索引会自动重建**。索引的 meta 表里存了嵌入器签名,
`build_index.py` 检测到签名变化就强制重建并打印警告:
```
!! 嵌入器已变更 (hashing:ngramce09b1 -> ollama:bge-m3),已强制重建索引
```
这不是多余的。`vector_search` 里有一句 `if len(v) != len(q): continue`——
维度不符的旧向量会被**静默跳过**,表现是「召回率突然掉了」而不是「配置错了」。
自动重建就是为了避免这种极难排查的故障。
## Agent 层:自检 → 改写 → 重查
这是本项目和「RAG 脚本」的分界线。
**流水线**的做法是:检索 → 拿到 top5 → 生成答案。检索到什么都用。
**Agent** 的做法是:检索 → **自检证据够不够** → 不够就改写检索词重查 → 再自检。
### 为什么必须有这一层
三个实测理由:
1. **检索永远返回 top5,哪怕全是不相关内容。** 没有自检,模型就拿着 5 段无关资料硬答,看起来很自信,实际是编的。
2. **用户提问和文档用词不一致时,第一次检索会漏。** 客人说「吃到变质的酒」,文档写「酒水异味或品质问题」——第一次召回的 top1 未必是正确段落,但只要自检发现「这些资料答不了」并把检索词改写成语料里的说法,第二次就能召回到。
3. **拒答是语义判断,不是打分任务。** README 里那个「拒答阈值标定失败」的结论,最终就是靠这一层解决的——检索分判断不了「资料够不够回答」,得模型说了算。
### A/B 对照实验
`scripts/probe_agent_ab.py`:同一批 24 条规则题,一组关掉 Agent 层,一组开着。
| 指标 | A 关掉 Agent | B 开启自检重查 | 变化 |
|---|---|---|---|
| recall@1 | 79.2% | **87.5%** | **+8.3** |
| recall@3 / @5 | 95.8% / 95.8% | 95.8% / 95.8% | +0.0 |
| **答案引用正确来源** | 95.8% | 95.8% | **+0.0(零回退)** |
| 平均延迟 | 887ms | 945ms | +58ms |
| 最大延迟 | 1520ms | 1633ms | +113ms |
| 触发重查 | 0/24 | 6/24 | +6 |
| 重查后 top1 **变差** | — | **0** | 零回退 |
**recall@1 涨 8.3 个点,答案正确率零回退,延迟基本不变。**
成本几乎为零是因为判定分两层:规则层(词面重合度)先行,模型层只在灰色地带才调用。
### 实现要点
**判定分两层**,这是成本能压下来的关键:
| 层 | 判据 | 成本 |
|---|---|---|
| 规则层 | 工具拿到数据 → 直接算够;top1 词面重合度 ≥ 0.55 → 算够;≤ 0.15 → 算不够 | 零 |
| 模型层 | 规则层说不清楚时,才让 LLM 判断「这些资料能回答吗」 | 一次调用 |
**改写词用拼接而不是替换。** 「客人吃到变质的酒怎么办」改写成「酒水异味 品质问题」之后,
「变了」「客人」这些用户信号就丢了。实际做法是把改写词拼在原句前面,
让 BM25 同时吃到两边的词。
**每一轮独立判定,最终只用某一轮的片段。** 这一点是踩坑踩出来的:
早先版本把几轮召回取并集,好处是「重查不会让结果变差」,
但坏处更严重——三轮召回混在一起,模型看到的是一锅粥。
实测「客人边吃饭边做爱怎么办」这条,top1 修对了(升级与上报),
答案却变成「现有资料里没有相关规定」,**比没有 Agent 层时更差**。
检索指标涨了,答案质量反而跌了。
所以现在的规则是:某一轮判 OK 就用那一轮,全都不 OK 就用分数最高的那一轮。
**模型层有兜底。** 第一版提示词里写了「OK 时第二行输出 `-」」,
结果模型在 MISS 时也照抄 `MISS\n-`,改写词永远是空的,
整个重查机制等于空转(实测 10 条用例,重查修正 top1 的数量是 **0**)。
修法有两个:提示词里给两种情况的示例,以及代码层兜底——
模型不给改写词时,从语料的章节标题里挑语义最接近的当检索词。
**重查上限是 2 轮。** 语料里真没有的内容,改写十次也找不到,
反而拖长延迟、让 trace 变得没法看。
### 怎么关掉它
```bash
set SM_AGENT_MAX_ATTEMPTS=0 # 退回纯流水线
```
A/B 实验就是这么做的。
## 评估结果
金标集 31 条(24 条规则题 / 5 条数据题 / 2 条应拒答题),`python scripts/eval.py` 可复现:
### bge-m3 嵌入 + qwen2.5:7b 生成(当前默认配置)
| 指标 | 数值 | 字符哈希时期 |
|---|---|---|
| recall@1 | 87.5% | 81.0% |
| recall@3 | 95.8% | 90.5% |
| recall@5 | 95.8% | 95.2% |
| 文档命中 | 95.8% | 95.2% |
| **答案关键内容命中** | **100.0%** | 不适用(当时无生成模型) |
| 数据题工具选对 | 100.0% | 100.0% |
| **无答案题正确拒答** | **100.0%** | 0%(当时无 LLM,无法拒答) |
| 路由准确率 | 100.0% | 89.3% |
| 编排等价性 | 6/6 | 6/6 |
> recall@1 当前为 87.5%(bge-m3 + PG)。自检重查的判定由模型做,重复运行会有波动;
> 下面的 A/B 对照里 A 组 79.2%、B 组 87.5%,这组差异才是「Agent 层带来多少」的量化。
**端到端真实验证**(`scripts/verify_e2e.py`,走 HTTP 打真模型,11 条用例):11/11 无思考过程泄漏,10/11 章节命中。
**答案稳定性**(`scripts/probe_stability.py`,同一问题连跑 5 次):关键内容 5/5 稳定。
**PostgreSQL 后端实跑**:87.5% / 95.8% / 95.8%,A/B 延迟 +58ms。所有验证脚本默认连
`deploy/docker-compose.yml` 里的 postgres(宿主 5433),不再有第二套存储。
### 关于向量权重的对照实验
`scripts/weight_sweep.py` 扫 8 档权重,用同一份金标集量:
| 嵌入后端 | 权重 0.0 | 0.15 | 0.5 | 1.0 | 2.0 |
|---|---|---|---|---|---|
| 字符哈希 | 81.0% | **81.0%** | 81.0% | 81.0% | 81.0% |
| bge-m3 | 85.7% | 85.7% | 85.7% | **85.7%** | 85.7% |
结论有点反直觉,但 `scripts/diagnose_dual.py` 解释清楚了:
在 bge-m3 + 当前 45 个 chunk 的规模下,**两路召回都是 100% 命中**(21/21 规则题,
向量路和 BM25 路都召回了正解),所以调权重看不出差别。
这说明向量路从 hashing 时代的「负资产」变成了「等效资产」——不是「更好」,是「不再拖后腿」。
> hashing 时代把权重压到 0.15,是因为实测「满减和折扣券能一起用吗」这条
> BM25 把正解排第 1,向量路却把「抖音素材留存」排第 1,RRF 奖励「两路都出现」
> 反而把正解稀释到第 3。**同一个参数在换嵌入模型后必须重新标定**,
> 这也是我给索引加了嵌入器签名的原因——见下面第 6 条。
## 本地模型方案
不用任何云 API。本机 RTX 5060(8GB)跑 ollama:
```bash
scoop install ollama
ollama serve # 默认 127.0.0.1:11434
ollama pull bge-m3 # 1.2GB,嵌入
ollama pull qwen2.5:7b # 4.7GB,生成
```
| 用途 | 模型 | 体积 | 热态延迟 |
|---|---|---|---|
| 嵌入 | bge-m3 | 1.2GB / 1024 维 | 单条 14ms,45 条批量 9.3s |
| 生成 | qwen2.5:7b | 4.7GB | 400–900ms/次 |
**为什么不用 qwen3(混合推理模型)**——`scripts/probe_models.py` 四种配置实测:
| 配置 | 输出 | 耗时 | 结论 |
|---|---|---|---|
| qwen3:4b `think=false` | 593 字,混着「规则要求」的复述 | 3.8s | 不合格 |
| qwen3:4b `think=true` | content 空,推理 1287 字没跑完被截断 | 8.1s | 不合格 |
| **qwen2.5:7b** | **39 字,干净带引用** | **401ms** | **采用** |
附带一个发现:qwen3:4b 在「储值 2000 送多少」上答的是「赠送 2.00 元」,
而语料原文是 300 元——**小模型连「原样引用数字」这条硬要求都做不到**。
qwen2.5:7b 答的是 300 元。系统提示词里第 3 条「涉及金额必须原样引用」不是摆设,
但只有更强的模型能真正执行。
**keep_alive 是个坑**:ollama 默认 5 分钟后卸载模型,下次请求要重新加载权重,
延迟从 0.4 秒跳到 17 秒。所以每次请求都显式带 `keep_alive: "30m"`。
## 六个关键设计决策
面试被追问的就是这几条,每条都有对照实验或数据支撑。
### 1. 为什么不按固定 token 切片
门店 SOP 的知识单元是「章节」,一个章节讲一件事。按 512 token 硬切会把「跨店核销的三种情况」切成两半,检索出来的全是残句。
所以按 Markdown 标题层级切分,并保留 `section_path`(如 `活动核销规则 > 叠加规则`)。嵌入时把标题路径拼在正文前——标题是极强的人工语义标签,扔掉可惜。
**踩过的坑**:第一版 overlap 是直接截上一块最后 60 个字符贴到下一块开头,结果每个 chunk 开头都是半句话,生成时模型读到错位的上文。改成按完整句子从后往前取,且只在同一章节内做 overlap。
### 2. 为什么用 PostgreSQL + pgvector,而不是独立向量库 / 纯内存检索
部署路径是 PostgreSQL + pgvector:知识库向量、经营数据、工作流/审计、LangGraph checkpoint 都在同一个库,能做 JOIN、走事务、备份一套搞定;比 Qdrant / Milvus 少一个要运维的组件。
**运行时只有这一个后端**:`build_store` / `build_ops_tools` / `WorkflowDB` / `PostgresSaver` 全部固定 PG,SQLite 已移除。本地跑脚本需要 compose 的 postgres(宿主 5433);Docker 一键起。
**为什么不把 BM25 下推给 PG**:`chunk_terms` + Python 打分保留在应用层,换成 `tsvector` 是 TF-IDF 类实现,会改变词法召回;中文全文检索的生产方案是 zhparser / pg_jieba。向量走 HNSW,词法走应用层 BM25,RRF 融合也在应用层,k 和权重能跟着评估集调。
**小数据不是 PG 的强项**:45 个 chunk 下 HNSW + 网络往返有明显固定开销,单次 `hybrid_search` 约 60ms(含 bge-m3 嵌入);PG 的收益在并发、多实例和 10 万 chunk 以上的 ANN,不是单机小数据。这个结论比“PG 更快”更值得讲。
### 3. 为什么要混合检索,以及一个反直觉的发现
只靠向量召回会漏掉专有名词——「黑牌」是威士忌不是颜色,「连娱宝」是产品名,这类词在嵌入空间里和字面完全不相关。只靠关键词召回则对「那如果客人喝了半瓶怎么办」这种口语长句几乎无效。所以两路都召,用 RRF 融合。
**关键发现**:字符哈希嵌入在这类查询上是**负资产**。
> 用例「满减和折扣券能一起用吗」:BM25 单独把正解「叠加规则」排第 1(40.9 分,领先第二名 60%),向量路却把「抖音素材留存」排第 1。RRF 会奖励「两路都出现」的片段,于是正解被稀释到第 3。
>
> 把向量权重从 1.0 降到 0.15 后,正解回到第 1。
RRF 的副作用不是加噪声,是**稀释**。
**换 bge-m3 之后的结论不一样**:`scripts/diagnose_dual.py` 实测两路召回都是 100% 命中,
向量路从「负资产」变成「等效资产」。但它**没有变成决定性因素**——
因为 BM25 在这个语料规模上已经够用。所以 0.15 这个值不需要改回 1.0,但也不能说向量路有决定性贡献。
> 这个结论本身就是面试素材:**混合检索里两路的价值不是固定的,取决于嵌入模型的质量。**
> 参数不能跨后端照搬。
### 4. 为什么不让 LLM 自由生成 SQL
三个理由:注入面(门店名来自用户输入)、幻觉(模型会编不存在的表名,线上表现是随机 500)、权限不可控(自由 SQL 意味着能 SELECT 员工薪资表)。
所以只暴露白名单工具,参数用 Pydantic 校验,SQL 全部预编译。**模型能做的是「选哪个工具、填什么参数」,不是「写 SQL」。**
**踩过的坑**:第一版用正则 `[\u4e00-\u9fff]{2,6}店` 抽门店名,「上周广州天河店卖了多少」整段被吃成门店名「上周广州天河店」,查库必然为空。改成门店白名单匹配,顺带解决了简称(天河店 → 广州天河店)。
### 5. 为什么规则路由优先于 LLM 分类
门店高频问题高度模式化,「能…吗 / 怎么 / 规定」这类句式用正则判断准确率高于小模型,而且零成本、零延迟。规则命中不了才走 LLM——不是所有环节都值得塞一次模型调用。
**踩过的坑**:第一版用关键词包含判断,「会员卡能跨店用吗」不含「能不能」三个字所以没命中。改成句式正则(`能[^,。?!]{0,10}[吗嘛]`)后路由准确率上来了。
**第二个坑**:`_match` 按 pattern 计数而不按命中次数。「深圳福田店热销排行前五」里热销、排行、前五三个词都落在同一个 pattern,只算 1 个弱信号,不足 2 个的门槛,被误判成规则意图、跳过了数据查询。改成 `findall` 计数后路由准确率 89.3% → 100%。
### 6. 为什么先手写状态机,再迁到 LangGraph
不是一开始就用图。先把状态机的形状自己画一遍,才知道哪些节点该共享、哪些边该有条件、哪些状态需要持久化。手写版跑通、评估集跑出数字之后,迁移是平移——节点函数一行没改,只换了编排。
**迁移的真实收益**(不是"用了框架"这种理由):
- **并行 fan-out**。手写版要自己管「两路都要」时的并发和异常合并;图里 `add_edge("route","a")` + `add_edge("route","b")` 就是并行,merge 自动等两边。
- **checkpoint**。多轮会话持久化在手写版是自己维护一个 dict,图里是 `thread_id` 的事,还附带中断恢复。state 落在 PostgreSQL,服务重启会话还在。
- **可观测**。每个节点、每条边都在 trace 里,出事能定位到边。
- **环**。这是加 Agent 层之后才体会到的。手写版的自检重查是一个 `for` 循环加一堆状态回退;
图里就是 `judge → rewrite → retrieve_again → judge` 两条边。
**但图版不能直接循环回 `retrieve_guard`**——它是 join 的成员,循环回去会破坏 join 语义。
所以得单独开一个 `retrieve_again` 节点。
**迁移踩了三个坑,都不在文档显眼处:**
1. **join 的语义是「等待列表里每个节点都完成」,不是「等待已激活的节点」。** 我先用 `add_edge(["retrieve","fallback"], "answer")`,policy 路径下 fallback 不激活,answer 就永远等不到——实测答案是空字符串。改成两个 guard 节点必然执行(不需要的那条返回空 dict),join 才成立。
2. **多入边的节点会被每条边各触发一次。** 第一次改完 both 意图下生成跑了两次,trace 里两个 `generate`,后一次把前一次覆盖了。所以必须有唯一的汇合点。
3. **trace 不能放进 state。** state 会被 checkpointer 持久化,`steps` 用了 `operator.add` reducer 之后跨轮次累积——第二轮能看到第一轮的步骤。trace 是运行时观测数据,改用 `contextvars` 在 state 外面收集。
这三个坑写在 `app/agent/graph.py` 的注释里。
**第四个坑是加 Agent 层时踩的**:环不能回到 join 成员上。
`retrieve_guard` 和 `data_guard` 组成 join,重查如果循环回 `retrieve_guard`,
join 等的是「本次 superstep 里两个成员都到过」,只回一个成员就永远等不到另一个。
解法是单独开 `retrieve_again` 节点,不参与 join。
**最终验证**:手写版(while 循环)和图版(图里的环)跑同一批 query,
intent、top1 章节、召回集合全部一致,**6/6 通过**。
这才是「我用了 LangGraph」这句话该有的证据强度——
不是「框架能跑」,而是「同一套逻辑两种编排,输出可验证地相同」。
### 7. 防幻觉的四道防线
接上 LLM 之后,「答案看起来对」不等于「答案对」。实测抓到四类真问题,每一类都加了一道代码层的防线——**不指望模型自己守规矩**。
| 抓到的问题 | 现象 | 防线 |
|---|---|---|
| **引用捏造** | 纯数据查询(召回 0 段)时模型凭空写 `[2]` | `_strip_bogus_citations`:只有落在实际召回/数据范围内的编号才保留 |
| **来源混淆** | 参考资料有编号、经营数据没编号,模型给数据编了个 `[1]` | 经营数据改用 `[D1]` 前缀,编号体系分离 |
| **数据答不全** | 工具返回 2 个告急品项,答案只说了 1 个 | `_ensure_all_rows_mentioned`:代码检查每条目名是否出现,漏了直接补上 |
| **条件性条款被跳过** | 客人说「吃到坏了」,答案完全没提「身体不适要就医」 | `_ensure_mandatory_rules`:按场景关键词强制补上关键条款 |
第 3 条值得单独说:**改 prompt 根本不管用**。我试过给每个数据行加编号、
在系统提示词里写「不要只挑其中一条」、把字段名翻成中文——模型还是会漏。
7B 模型在列表上就是会只取第一条。最后是代码兜底才解决的。
第 4 条更有分量。SOP 里大量条款是**条件句**——「如果客人提出身体不适,立即建议就医」,
而客人问「吃到坏了怎么办」时并没说「身体不适」。模型读到这条会理解成
「当前场景不满足条件」而跳过。实测「客人吃到了坏了怎么办」连跑 5 次,
「建议就医」出现 **0/5**——不是随机性,是系统性遗漏。
> 这在食品安全场景是真实事故风险:漏掉就医建议,
> 后面所有流程(退单、赔付)都建立在「客人身体无恙」的假设上。
> 所以这类条款不能靠模型自觉,按场景关键词在代码层强制补上。修完 0/5 → 5/5。
>
> **在有确定答案的场景,正确性不能交给概率模型。**
> 门店店长拿着这份提示去补货,漏一个品项就是真实的业务事故。
> **在有确定答案的场景(结构化查询),正确性不能交给概率模型。**
另一个相关决策:**等价性测试的判据改了**。
接上 LLM 之后,两次调用同一问题会因采样随机性产生不同措辞,
逐字比对答案必然失败——那测的是 LLM 的随机性,不是编排的正确性。
改成比对「intent + top1 章节 + 召回集合」,这才是编排真正决定的部分。
(另外提取数字前要先剥掉 `[1]` 引用标记,否则引用编号会被当成内容数字。)
还有一个配套的评估维度:**答案关键内容命中**。
recall@k 只说明「召回到正确的段落」,不说明「答案里真的包含了关键信息」。
金标集加了 `expect_answer_contains` 字段后,p22(吃到坏了)这条的
「章节命中 @3」和「内容命中 ✔」是两个独立信号——内容漏了但章节命中,在旧指标下是看不出来的。
### 8. 为什么索引要记录嵌入器签名
换嵌入后端时如果忘了重建索引,**不会报错**。
`vector_search` 里有一句 `if len(v) != len(q): continue`——
维度不符的旧向量被静默跳过,表现是「召回率突然掉了」而不是「配置错了」,极难排查。
所以索引的 meta 表里存了嵌入器签名(`hashing:ngramce09b1` / `ollama:bge-m3`),
`build_index.py` 检测到签名变化就强制重建并打印警告:
```
!! 嵌入器已变更 (hashing:ngramce09b1 -> ollama:bge-m3),已强制重建索引
```
> 签名里**不含 dim**:ollama 的向量维度要等首次嵌入后才知道,
> 写进签名会导致每次运行都判定为「变了」而误触发重建。
> hashing 那边已经把维度编码进模型名里了。
## 已知限制
写在最前面,因为这几条我试过并且知道为什么难:
1. **语料是构造的**。8 篇文档、45 个 chunk,按「连娱宝」公开功能描述反推知识域造的。企业知识库项目拿不到真实数据,所以重点放在语料分布设计:非结构化规则 + 结构化事实 + 含糊提问三类混合。规模上也偏小——45 个 chunk 上 BM25 已经够用,向量路的贡献被掩盖了。语料扩到几百篇后,两路的互补性才会显现。
2. **top1 排序不理想,这是当前最明显的短板**。以「什么情况必须叫店长」和「客人吃到了坏了怎么办」为例,正解都在召回列表的第 2–3 位,第 1 位是「第一周必须掌握的六件事」「三条基本原则」这类泛化段落。答案内容正确、引用编号也正确(指向第 2–3 段),但用户点 top1 看到的不是最相关的那段。**上 rerank 模型能解决**(bge-reranker),当前只做了章节路径加权这种规则重排。语料只有 45 个 chunk 时影响有限,扩到几百篇后会变成主要瓶颈。
3. **生成模型只有 7B,指令遵循不是满分**。qwen2.5:7b 在 11 条端到端用例里做到 0 次思考泄漏、0 次引用捏造,但仍偶发输出「首先确认是…」这类正常中文开头(不算泄漏)。系统提示词里的格式约束只能约束大部分情况,剩下的靠 `_strip_lead_noise` 后处理兜底。
4. **纯数据查询不做 RRF**。数据意图直接走工具,不检索。好处是快、准;代价是无法回答「结合历史订单看这个品项的动销趋势」这类需要文档 + 数据交叉的问题。
5. **没有 rerank**。用的是章节路径加权(query 词命中章节标题加分)这种规则重排,没有 bge-reranker 这类交叉编码器模型。45 个 chunk 规模下不需要,chunk 上到几百就该加了。
6. **Agent 层的判定是「够不够」,不是「对不对」。** 自检只回答「这些资料能回答这个问题吗」,不验证答案本身对不对。判 OK 之后模型仍可能答错——那要靠别的手段(交叉验证、人工抽检)。另外判定器本身有误判率:6/24 的用例触发了重查,其中多数第一轮其实已经够用,属于无谓开销。
7. **改写是关键词层面的,不是语义层面的。** 靠模型给几个术语拼在原句前面,对「絮状物 → 酒水异味」这种有效,对「客人投诉了但没说原因」这种无能为力。后者要真正的 query rewriting(用 LLM 生成完整的检索式),当前版本没做。
8. **强制条款是关键词匹配,不是真正的条件推理**。`_ensure_mandatory_rules` 靠触发词命中来补条款,能挡住「吃到坏了」这类明显场景,但挡不住「客人喝完第二天不舒服」这种换了说法的情况。要做对得让模型显式判断条件是否成立,那是另一个量级的工程。
## 目录
```
app/
config.py 全部可插拔配置,环境变量驱动
ingest.py 索引构建(按内容 hash 增量)
main.py FastAPI 入口
api/ 接口层(薄,只做校验和异常映射)
agent/
router.py 意图路由(句式正则,强/弱信号分层)
judge.py 证据充分性判定 + 查询改写(Agent 层的核心)
pipeline.py 手写状态机:route → retrieve/query → judge/rewrite 循环 → answer
graph.py LangGraph 编排(并行 fan-out + 自检重查环 + PostgreSQL checkpointer)
llm.py 生成层(none / ollama / openai 兼容)
rag/
chunker.py 标题层级语义切分
embedder.py 嵌入层(hashing / ollama)
store.py 共享类型(Hit / tokenize)与 build_store 工厂
pg_store.py PostgreSQL + pgvector(HNSW),唯一存储实现
retriever.py 混合召回 + 加权 RRF + 路径加权重排
tools/
ops_tools.py 共享类型(ToolResult / Period)与 build_ops_tools 工厂
pg_ops.py 白名单经营数据工具(PostgreSQL)
workflow/
db.py 操作提案 / 审计 / 工单表(PostgreSQL)
service.py 提案 → 确认/拒绝,权限、幂等、审计
mcp/
server.py 自实现 MCP server(JSON-RPC 2.0 + tools/resources + stdio/HTTP)
static/
index.html 单文件前端页面,零构建,fetch 直接打 /chat
corpus/ 门店运营语料(sop / rules / product)
eval/golden.json 金标集 31 条
scripts/
seed_ops_db.py 生成经营数据
build_index.py 建索引(增量 + 嵌入器签名检测)
search_cli.py 命令行检索
diagnose.py 分离两路召回
diagnose_dual.py 两路命中率对比(判断向量路是否有贡献)
weight_sweep.py 向量权重对照实验
eval.py 跑金标集出 recall@k
smoke_test.py 端到端冒烟
calibrate.py 拒答阈值标定(结论:不可行,见设计决策 7)
compare_orchestration.py 手写编排 vs LangGraph 等价性 + 多轮演示
probe_models.py 生成模型选型对照(qwen3 vs qwen2.5)
probe_think.py think 参数行为探测
probe_stability.py 答案稳定性:同一问题连跑 N 次统计关键内容命中率
probe_agent.py Agent 层实测:自检重查是否真的修正了 top1
probe_agent_ab.py Agent 层 A/B 对照:开关自检重查的收益量化
verify_e2e.py 真模型端到端验证(走 HTTP,11 条用例)
mcp_server.py MCP server 启动入口(不依赖 cwd)
mcp_smoke_test.py MCP 协议冒烟:握手 / 通知 / 工具 / 资源 / 三类错误码
verify_docker.py Docker 部署验证:app 三个接口 + MCP 四个方法 + 工作流
docker.ps1 中文 Docker 快捷命令(启动 / 启动模型 / 状态 / 日志)
workflow_test.py 工作流:提案/确认/越权/幂等/审计
dump_prompt.py 导出真实 prompt 排查
dump_chunks.py 导出语料切片结果,定位某句话落在哪一段
dump_judge.py 导出 judge 模型的原始输出,查解析失败
setup_local.bat 一键起 ollama + 拉模型 + 建索引
deploy/ docker-compose(ollama + PG + app + MCP)与 pgvector schema
mcp.example.json MCP 客户端配置示例
```
## 路线图
- [x] LangGraph 编排:并行 fan-out + checkpoint 多轮会话
- [x] 单文件前端页面:意图徽标 + 执行轨迹 + 可展开引用溯源(文档引用 + 数据引用分别可点)
- [x] 本地模型链路:ollama + bge-m3 + qwen2.5:7b,零云 API 依赖
- [x] 防幻觉四道防线:引用校验 + 编号分离 + 数据条目兜底 + 条件性条款强制
- [x] 拒答:交给 LLM 做语义判断,评估报告里从 N/A 变成 100%
- [x] 答案稳定性验证:同一问题连跑 5 次,关键内容 5/5
- [x] **Agent 层:自检 → 改写 → 重查循环**。图里唯一的条件边和环,
A/B 对照 recall@1 +8.3 个点、答案正确率零回退、延迟 +58ms
- [x] **自写 MCP server**:自己实现 JSON-RPC 2.0 与 MCP 握手,暴露 13 个工具 + 8 篇语料资源,
stdio / HTTP 两种传输,协议冒烟 14/14(含零依赖模式)
- [x] **可执行工作流**:客诉工单 / 补货申请,提案 → 人工确认 → 落库 + 审计;
角色权限、幂等、拒绝全部覆盖,测试 9/9
- [x] **Docker 化**:`docker compose up` 起 PG + app + MCP,无需 GPU / 模型也能跑;
镜像 334MB,部署验证 10/10(hashing + none / ollama 各测一次)
- [x] **全栈 PostgreSQL + pgvector**:HNSW 索引,知识库 / 经营数据 / 工作流 /
会话 checkpoint 都在 PG;SQLite 已从运行时移除
- [ ] Redis 语义缓存:query embedding 相似度 > 0.95 直接返回
- [ ] 加 rerank 模型(bge-reranker),解决 top1 排序不理想这个最大短板
- [ ] LLM 版指代消解:当前「那退卡呢」靠规则借上文,复杂追问会失效
- [ ] 语料扩到 20 篇以上,让混合检索的两路互补性显现出来
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues