niserson/payback-assistant
GitHub: niserson/payback-assistant
一个轻量级的德英双语商品检索微服务,结合机器学习分类器、BM25检索与LLM实现低延迟的意图识别与跨目录产品推荐。
Stars: 0 | Forks: 0
# PAYBACK 轻量级助手
[](https://github.com/niserson/payback-assistant/actions/workflows/ci.yml)
一个轻量级的 microservice,它接收原始的用户查询(德语或英语),检测
**intent**(`search` / `discovery` / `comparison` / `customer_support`)和 **语言**,
并返回结构化的 JSON 响应:可以是在三个 partner 生态系统中检索到的 **推荐产品**、
一个 **澄清问题**、一个 **partner 路径**,或者一个 **support 移交**。
刻意构建得极其精简(奥卡姆剃刀原则):一个基于机器学习的查询分类器(TF-IDF +
逻辑回归 —— 无需手工编码的词典,在构建时通过合成的标注数据训练而成)
+ 纯 Python 实现的 BM25(基于目录自身的中英双语标签)。没有 torch,不需要
GPU,冷启动在几毫秒内完成,且每个决策都是可审计的。模块边界(`intent`、
`retrieval`、`agent`)是清晰的接缝,日后可以在不触及 API 契约的情况下替换为
LLM 或 embedding 模型。
**混合理解:** 确定性的分类器路径会在约 1 毫秒内尽可能回答所有问题;
只有当查询中不包含可检索的产品词汇时,才会升级到 Gemini(在 Cloud
Run 上使用 Vertex AI,在本地使用 AI Studio),由其将需求翻译为德语目录关键词或提出
澄清问题 —— 如果 LLM 不可用,则会静默回退到分类器。内置的聊天 UI 部署在 `/`。
## 快速开始(本地,无需 Docker)
```
pip install -r requirements-dev.txt
python -m uvicorn app.main:app --port 8080 # start the API
python demo.py # 5+ demo queries, in-process (no server needed)
python demo.py --url http://localhost:8080 # same, against the running server
python -m pytest -q # test suite
```
调用示例:
```
curl -X POST http://localhost:8080/assist \
-H "Content-Type: application/json" \
-d '{"query": "Bitte zeige mir Angebote für günstige Windeln"}'
```
## 架构
```
flowchart LR
U[User query de/en] --> API[FastAPI /assist]
API --> I[Learned classifier TF-IDF+LogReg
intent, language, price, vagueness] I -->|specific| R[BM25 retrieval] I -->|vague| C[Clarifying question] I -->|navigational| P[Partner-scoped search] I -->|support| S[Support handoff] subgraph Unified index - one BM25 over all partners A[(Partner A: dm
drugstore)] B[(Partner B: EDEKA
grocery)] C3[(Partner C: Amazon
long-tail)] end A & B & C3 -->|ingestion: normalize, umlaut-fold,
stem, field-weight| IDX[Inverted index over
bilingual catalog tags] R --> IDX P --> IDX IDX --> J[Structured JSON response] C --> J S --> J ``` **三个完全不同的目录是如何同时被索引和查询的:** 每个 partner 目录在导入时被规范化为一个共享的产品 schema(`id, partner, name, brand, category, price, unit, tags, popularity`),并被索引到一个 **单一** 带有字段权重(name > brand/tags > category)的倒排索引中。查询会被 分词(tokenized),进行变音符号/二合字母折叠(umlaut/digraph-folded)以及轻度词干提取,然后使用 Okapi BM25 对 **所有** partner 同时进行评分 —— 跨语言匹配得益于目录自身 双语标签的支持,而目录词汇表之外的词汇将升级给 LLM —— `partner` 字段仅仅是一个过滤器,仅应用于导航类查询。排名会将 BM25 相关性 (85%) 与全局流行度先验 (15%) 混合,并在查询表现出价格敏感倾向时提供低价权重加成 ("günstig", "cheap", "Angebote")。 **冷启动** 问题在结构上得到了解决:排名仅使用查询上下文 + 全局 先验 —— 系统中不存在任何用户历史记录。 ## API | Endpoint | Method | Description | |---|---|---| | `/assist` | POST | `{"query": str, "max_results": int, "user_id": str}` → 结构化响应 | | `/health` | GET | 存活状态 + 索引大小 + LLM 后端 | | `/partners` | GET | partner 目录元数据 | | `/taxonomy` | GET | 各 partner 的类别树及产品计数 | | `/architecture` | GET | 架构图 (HTML + SVG) | | `/demo-notebook` | GET | 执行过的演示 notebook(5 个查询,JSON 输出) | | `/performance-report` | GET | 执行过的负载测试 notebook,包含每 1000 次请求的实测成本 | | `/docs` | GET | OpenAPI UI | **用户上下文:** 每次查询都会更新用户兴趣画像(基于返回产品的类别, 计算每个类别的百分比)。该画像在 **两层中承担 30% 的权重**:它会被注入到 Gemini 的 prompt 中(用于解决模糊查询, 使其倾向于用户的主导类别而非提出澄清问题),并且检索过程会将 每个产品的得分乘以其类别的 `1 + 0.3 × 兴趣份额`(在得分相近时,受偏好的类别 将胜出)。存储在每个实例的进程内进行,利用 Cloud Run 的会话亲和性 将用户固定到一个实例上(演示级别范围);`app/context.py` 是生产环境中对接 Firestore/Memorystore 的接缝。请参阅 `/coldstart-notebook` 以获取实时的冷启动 与用户画像对比。使用 `python scripts/build_notebooks.py --base` 针对实时部署重新构建 notebook。
响应结构(参见 `app/schemas.py`):
```
{
"query": "...", "language": "de", "intent": "search", "confidence": 0.85,
"action": {"type": "recommend", "detail": "..."},
"partner_filter": null,
"products": [{"id": "dm-001", "partner": "dm", "name": "Windeln ...", "price": 4.87, "score": 9.1, "...": "..."}],
"clarifying_question": null,
"latency_ms": 0.4
}
```
## Agent 策略 (Next Best Action)
| 检测意图 | Action |
|---|---|
| 明确的产品需求 | `recommend` — 跨 partner 的 BM25 搜索 |
| 模糊的需求 | `clarify` — 提问,并在存在历史记录时提供基于兴趣的建议 |
| 提到了 partner (dm/EDEKA/Amazon) | `route_to_partner` — 限定在 partner 范围内的搜索 |
| 比较 ("besser", "oder", "vs") | `compare` — 对最佳匹配进行并排对比 |
| 支持 ("Problem", "Punkte", "refund") | `support_handoff` |
## Docker
```
docker build -t payback-assistant .
docker run -p 8080:8080 payback-assistant
```
镜像:`python:3.12-slim`,非 root 用户,目录在构建时内置,压缩后约 60 MB。
## 云端部署(首选服务 —— 已部署并验证)
`scripts/deploy_cloudrun.sh [region]` 通过 Cloud Build 构建并部署到
**Cloud Run**(1 vCPU / 512 MiB,缩放至零,最多 3 个实例,并发数 80)。该
服务是无状态的 —— 索引在实例启动时基于内置目录在 100 毫秒内完成重建 ——
因此水平扩展非常简单。
所有三个首选服务均得到了应用:
- **Cloud Run (API)** — 托管容器;提供 UI、`/assist`、`/docs`。
- **Vertex AI (模型服务)** — 在云端,LLM 路径通过
Vertex AI endpoint 调用 Gemini,由 Cloud Run 的 *服务账号* 通过 metadata
服务器进行身份验证(`VERTEX_PROJECT` 环境变量;无需部署 API 密钥)。在本地,设置
`GEMINI_API_KEY` 以改用 AI Studio;如果两者均未设置,服务将仅运行分类器。
响应通过 `engine` 字段暴露由哪条路径进行了回答
(`classifier` 对比 `classifier+gemini-2.5-flash-lite@vertex-ai`)。
- **BigQuery (向量搜索)** — `scripts/bigquery_vector_search.py --project ""`
使用 Vertex AI 的 `text-embedding-005` 对所有 partner 目录进行 embedding 处理,将其加载到
`payback_assistant.products` 中,并运行语义 `VECTOR_SEARCH` (余弦相似度)。已验证:
查询 *"Ich brauche etwas gegen wunden Po bei meinem Kleinkind"* —— 与任何产品均无关键词
重合 —— 返回了 Feuchttücher/Windeln/Schnuller。这是
生产规模的检索路径;服务 API 保留了内存中的 BM25 + LLM 词汇
扩展,这在演示目录规模下速度更快且成本更低。
## 负载测试与成本
```
python -m uvicorn app.main:app --port 8080 # terminal 1
python loadtest.py --requests 500 --concurrency 20 # terminal 2
```
报告吞吐量、p50/p95/p99 延迟以及基于 Cloud Run 按请求计费估算的 **每 1000 次请求的成本**
(按观测到的吞吐量计算 vCPU + 内存 + 单次请求费用)。
在单个本地 uvicorn worker 上的实测结果(Windows 环境,1000 个请求,并发数 20):
```
errors=0 throughput=364 req/s p50=14.0ms p95=20.7ms
est. Cloud Run cost per 1000 requests (1 vCPU / 512 MiB): ~$0.0005
```
(p99 反映了首批客户端建立连接时的一次性预热过程;稳态
延迟体现在 p50/p95 区间内。)
## 性能报告
通过 *移除* 热路径中的推理过程而非对其进行优化,从而将延迟降至最低:
intent 检测是一个学习型的线性分类器(约 0.3 毫秒),检索是基于预构建的内存倒排索引进行的
BM25(时间复杂度为 O(查询词数 × 候选倒排记录数)),
因此端到端的处理程序耗时远低于一毫秒,且总响应时间主要由
HTTP 开销决定。双语目录标签加上算法化的字符折叠取代了
多语言 embedding 模型 —— 这是最大的延迟/成本优势 —— 同时
在此目录规模下保持了德语 ↔ 英语的召回率(已通过评估工具链验证)。索引在启动时一次性构建(而非每次请求时构建),
目录被内置到镜像中(没有冷启动 I/O),并且该服务是无状态的,因此
Cloud Run 能够以每实例 80 的并发量对其进行水平扩展。如果语义
召回率未来确实需要 LLM/embedding 步骤,计划是:在
BigQuery 中离线缓存 embeddings,保留 BM25 作为第一阶段检索器,并且仅对 top-k 结果进行重排序 ——
以此维持 p95 的预算不变。
## 项目布局
```
app/
main.py FastAPI app (endpoints, logging, error shielding)
schemas.py Pydantic contract
catalog.py synthetic 3-partner catalog (seeded, reproducible)
retrieval.py BM25 index over bilingual catalog tags, cold-start ranking
intent.py query understanding (learned classifier + index-grounded signals)
intent_model.py TF-IDF+LogReg training/inference (intent, language, price, vague)
agent.py next-best-action policy
tests/ unit + e2e tests (pytest)
demo.py 5+ queries -> JSON output
loadtest.py throughput/latency/cost measurement
Dockerfile slim, non-root
scripts/ Cloud Run deployment
```
## 评估
`evaluation/` 包含一个离线测试工具链:一个确定性的合成数据集
(**320 个标注示例**,涵盖不同的 intent、语言和 action,通过模板从
目录生成 —— 同时也作为 `payback_assistant.eval_examples` 加载到 BigQuery 中)
以及一个指标运行器。真实标签相关性独立于检索器(仅限于字面意义的
标签/名称匹配),因此规范化 + 同义词层为自己赢得了分数。
```
python -m evaluation.harness # full report in <5s (deterministic classifier path)
```
当前结果:intent 99.1%,语言 99.7%,action 99.1%,partner 路由 100%,
Hit@5 0.993,MRR@5 0.981,NDCG@5 0.974(282 个检索示例)。CI 在每次推送时强制执行
阈值检查(准确率 ≥95%,Hit@5 ≥ 0.95,NDCG@5 ≥ 0.90)。执行
演示 walkthrough:实时服务上的 `/evaluation-notebook`。
## 超出范围(刻意的演示边界)
- 完整的会话信息和语义 ID(例如 SASRec 风格的序列推荐器)。
- 产品目录规模仅限于在合成生成中有意义的范围。
- 超出基本请求日志之外的可观测性/日志记录。
- 在测试/对照组上进行在线性能测量、A/A 测试、因果推断。
- 用户操作反馈(点击、浏览、展示) —— 不存在完整的购物门户网站或
客户旅程数据。
## 安全与正确性说明
- 严格的输入验证(Pydantic,长度受限的查询),无动态代码路径。
- 错误在服务端记录并屏蔽给客户端(不透明的 500 错误)。
- 非 root 容器用户;无密钥,无出站调用,完全确定性(已设置随机种子)。
- 测试套件涵盖语言/intent 分类、跨语言检索、partner
范围限定、agent 策略分支以及输入验证。
intent, language, price, vagueness] I -->|specific| R[BM25 retrieval] I -->|vague| C[Clarifying question] I -->|navigational| P[Partner-scoped search] I -->|support| S[Support handoff] subgraph Unified index - one BM25 over all partners A[(Partner A: dm
drugstore)] B[(Partner B: EDEKA
grocery)] C3[(Partner C: Amazon
long-tail)] end A & B & C3 -->|ingestion: normalize, umlaut-fold,
stem, field-weight| IDX[Inverted index over
bilingual catalog tags] R --> IDX P --> IDX IDX --> J[Structured JSON response] C --> J S --> J ``` **三个完全不同的目录是如何同时被索引和查询的:** 每个 partner 目录在导入时被规范化为一个共享的产品 schema(`id, partner, name, brand, category, price, unit, tags, popularity`),并被索引到一个 **单一** 带有字段权重(name > brand/tags > category)的倒排索引中。查询会被 分词(tokenized),进行变音符号/二合字母折叠(umlaut/digraph-folded)以及轻度词干提取,然后使用 Okapi BM25 对 **所有** partner 同时进行评分 —— 跨语言匹配得益于目录自身 双语标签的支持,而目录词汇表之外的词汇将升级给 LLM —— `partner` 字段仅仅是一个过滤器,仅应用于导航类查询。排名会将 BM25 相关性 (85%) 与全局流行度先验 (15%) 混合,并在查询表现出价格敏感倾向时提供低价权重加成 ("günstig", "cheap", "Angebote")。 **冷启动** 问题在结构上得到了解决:排名仅使用查询上下文 + 全局 先验 —— 系统中不存在任何用户历史记录。 ## API | Endpoint | Method | Description | |---|---|---| | `/assist` | POST | `{"query": str, "max_results": int, "user_id": str}` → 结构化响应 | | `/health` | GET | 存活状态 + 索引大小 + LLM 后端 | | `/partners` | GET | partner 目录元数据 | | `/taxonomy` | GET | 各 partner 的类别树及产品计数 | | `/architecture` | GET | 架构图 (HTML + SVG) | | `/demo-notebook` | GET | 执行过的演示 notebook(5 个查询,JSON 输出) | | `/performance-report` | GET | 执行过的负载测试 notebook,包含每 1000 次请求的实测成本 | | `/docs` | GET | OpenAPI UI | **用户上下文:** 每次查询都会更新用户兴趣画像(基于返回产品的类别, 计算每个类别的百分比)。该画像在 **两层中承担 30% 的权重**:它会被注入到 Gemini 的 prompt 中(用于解决模糊查询, 使其倾向于用户的主导类别而非提出澄清问题),并且检索过程会将 每个产品的得分乘以其类别的 `1 + 0.3 × 兴趣份额`(在得分相近时,受偏好的类别 将胜出)。存储在每个实例的进程内进行,利用 Cloud Run 的会话亲和性 将用户固定到一个实例上(演示级别范围);`app/context.py` 是生产环境中对接 Firestore/Memorystore 的接缝。请参阅 `/coldstart-notebook` 以获取实时的冷启动 与用户画像对比。使用 `python scripts/build_notebooks.py --base
标签:后端开发, 请求拦截, 逆向工具