执行计划:召回层 Qdrant hybrid + embedding 清洗 + 双栈定型#
分支
m-data-clean· 对齐 refdocs 04-0 / 04-1(主动更正:召回层 Faiss → Qdrant)· 衔接 ROADMAP M3 状态:选型与 spec 已定,待实现。本文是动手前的最终约定,实现以此为准。
0. 选型决策(为什么是这套)#
refdocs 04-1 给召回层定「纯向量 Faiss」,前提是训练好的语义塔。本项目无 GPU、 不训塔、用现成 BGE-M3 —— 前提不成立,纯 dense 漏掉电商高频 exact-match(品牌/型号/规格)。 故召回层改 hybrid:dense(BGE-M3) + 稀疏(BM25-style)。落到哪个库,经过一轮推演:
| 候选 | 出局/入选理由 |
|---|---|
| Faiss + 进程内 BM25 | dense 能扩,但进程内 BM25 单机内存、百万级就崩;上规模被迫拆成 Faiss+ES 双库手工拼(04-1 §6.4 痛批)。规模一进来即出局。 |
| OpenSearch(召回也用) | 向量侧过千万级被拖累;主链路 item_search 吃 docker、违背离线默认。 |
| Milvus | 亿级终局、04-1 钦定演进路径,但全量部署最重;项目大概率摸不到该量级,运维预支不值。 |
| Qdrant ✅ | 单引擎原生 dense+稀疏 hybrid + 分片量化(扩到十亿级)+ 增量 upsert;有纯 Python 本地模式满足离线默认;运维远轻于 Milvus。「现在本地跑 4427、将来同套代码指向集群」抽象规模不变。 |
本地模式已 spike 坐实(qdrant-client 1.18.0,:memory:,零 docker):dense(COSINE)+稀疏(IDF
modifier)+Query API 融合(RRF/DBSF)+payload 过滤全部跑通,排序符合语义。详见 §4 与 /tmp/qdrant_spike.py。
1. 最终架构:双栈(不是过度设计,是 04-1 双栈正解 + Faiss→Qdrant 升级)#
召回层 Qdrant items dense+稀疏 hybrid ← 本轮建(替换 Faiss)
应用层 OpenSearch category_insight RAG 知识库 ← M5 已建好,本轮一行不动
长期记忆 本地JSON/Redis memory(M7) ← 独立,不并入任何向量库
──────────────
Embed 统一 BGE-M3(TowerClient)三处共享 → 向量空间一致(04-1 §7.1 硬约束)plaintext- 不变量已核实:M5 KB(
build_category_kb.py:11+--require-remote门禁)与 items 召回都走get_tower_client()→ 同一 BGE-M3。一次 query 编码可同时打两栈,无空间漂移。 - 角色铁律:items 永不进 OpenSearch;KB cards 永不进 Qdrant;memory 不并入向量库。
- dev/CI:Qdrant 本地模式 + OpenSearch 本地回退 + 本地 JSON store → 零 docker(守 M3 离线默认)。
- prod:三者各司其职 —— 04-1 早已论证、愿意付的双栈成本。
2. 字段与检索文本(冻结,与库无关)#
2.1 Dense(进 BGE-M3,建索引时编码,存为 Qdrant 命名向量 dense)#
| 顺序 | 字段 | 处理 |
|---|---|---|
| 1 | title | 轻量归一(§3.2) |
| 2 | brand | 空跳过 |
| 3 | category | 取尾 3 层(tail_category(cat,3),§2.3) |
| 4 | description | 已重清洗(§3);句/词边界截到 ≤300 字符(§3.4) |
连接 " \| ",空字段跳过;拼好后整串跑一次轻量归一(§3.2)。
2.2 稀疏 / BM25(存为 Qdrant 命名向量 bm25,详见 §4.3)#
源文本 = title + brand + tail_category(cat,3)(不含 description:单字段词袋无法压权,
全量描述稀释 title、引杂散召回;description 语义价值由 dense 承担)。
分词:小写 → unicode \w+ → 丢长度 <2 token。
2.3 共享逻辑 app/recall/text.py(新)#
tail_category(category, n=3):按" > "取末 n 段重拼,不足原样。CATEGORY_TAIL=3可调。 例:...> Athletic > Running > Road Running→Athletic > Running > Road Running。bm25_tokens(title, brand, category) -> list[str]:建索引与 serve 同一函数,保证两侧分词一致。
2.4 Metadata → Qdrant payload(不再有 .meta.json)#
每个 point 的 payload = 原 sidecar 11 字段:
item_id · platform · title · brand · price · currency · rating · reviews_count · category · url · image_url
category存全 breadcrumb(展示/过滤),尾 3 层只用于检索文本。- 标量留作下游过滤/打分;不进任何检索文本。
3. 文本清洗与归一管道#
两层职责:内容清洗落 clean.py(描述,一次清洗全消费者受益),轻量归一在组装/截断时补。
3.1 描述内容清洗 clean_text(s, *, strip_html=True)(app/utils/clean.py)#
有序管道,对落盘 description 跑(并喂 detect_lang):
0. ftfy.fix_text(s) # 乱码/mojibake 修复(’ / é / 双重编码)
1. html.unescape(s) # 实体 &→& →\xa0 '→'(早做,连带解转义标签)
2. unicodedata.normalize("NFKC", s) # 全角→半角;全角空格 U+3000 / nbsp U+00A0 → 普通空格
3. re.sub(r"<[^>]+>", " ", s) # HTML 标签
4. re.sub(r"https?://\S+|www\.\S+", " ", s) # URL
5. s.translate(CJK_PUNCT) # 中文标点 ,。、;:!?()… → 半角,统一中英混用
6. EMOJI_RE / DECO_RE → " " # emoji(28%) / 装饰符 / 制表线
7. re.sub(r"([!?.•·*~_=-])\1{2,}", r"\1", s) # 折叠重复标点 !!!→! ---→-
8. re.sub(r"[\x00-\x1f\x7f]", " ", s) # 残余控制字符
9. re.sub(r"\s+", " ", s).strip() # 空白折叠
10. s[:DESC_CLIP] # 截 500(落盘上限)plaintextstrip_html=False 跳过 1/3/4(标题/品牌用)。幂等。
3.2 轻量归一(标题/品牌/组装串)#
clean_text(s, strip_html=False) 子集(步骤 0–2 + 5 + 6 + 8 + 9),覆盖:
乱码/编码、全角/半角、中英标点混用、全角空格、空格混乱。build 组装 embed_text 后整串跑一次。
3.3 符号集与标点映射#
EMOJI_RE = re.compile(
"[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U00002300-\U000023FF"
"\U00002B00-\U00002BFF\U00002190-\U000021FF\U0001F1E6-\U0001F1FF"
"\U0000FE00-\U0000FE0F\U0000200D\U000020E3]")
DECO_RE = re.compile(
"[•·▪▫►◄◆◇■□●○★☆※‣⁃∙\U00002500-\U0000257F\U00002580-\U000025FF]")
CJK_PUNCT = str.maketrans({
",": ",", "。": ".", "、": ",", ";": ";", ":": ":", "!": "!", "?": "?",
"(": "(", ")": ")", "【": "[", "】": "]", "—": "-", "~": "~", "…": ".",
"「": " ", "」": " ", "『": " ", "』": " ", "《": " ", "》": " ",
})python多语言安全:只动符号/emoji/控制字符/标点宽度,绝不删文字(拉丁/CJK/泰/阿拉伯)、变音符、®©™、数字货币。词内连字符 PK–2 保留(只折叠 3+ 连排)。
3.4 截断防半句(embedding 输入)#
对已清洗 description(≤500)截到 ≤300:① 候选 desc[:300];② [200,300] 内有句末标点
(. ! ?)则截到最后一个(含);③ 否则截到最后一个空格;④ 都没有硬截 300。
4. Qdrant 存储设计#
4.1 数据模型(一个 point 三合一)#
collection "globex_items"
named vectors: dense → VectorParams(size=1024, distance=COSINE) # BGE-M3
bm25 → SparseVectorParams(modifier=Modifier.IDF) # 稀疏,IDF 引擎算
payload: §2.4 的 11 字段(platform 用于过滤)plaintextCOSINE 下 Qdrant 内部自归一,dim 由 collection 强校验(写错维度即报错,省掉 Faiss 手写的维度守卫)。
4.2 双后端(沿用 M5/M7 套路,守离线默认)#
QDRANT_URL配了 → 连真 server(prod)。- 否则
QDRANT_PATH(默认data/qdrant/,gitignore)→ on-disk 本地模式(持久化,跨进程可读)。 - 测试 →
:memory:(建+查同进程,临时)。 - ⚠️ on-disk 本地模式单进程独占(文件锁):重建时需停 serve 进程;要并发就上 server。dev 可接受。
4.3 稀疏编码(DIY 轻量,无 fastembed/onnx)#
bm25_tokens(title,brand,tail3cat)(§2.3)→token → id = md5(token) % (2^31-1),value = tf。- collection 配
modifier=IDF→ Qdrant 在语料上算 IDF,query 时套用。 - 诚实:这是
tf × idf(BM25-lite),无 k1 饱和 / 长度归一。够当 exact-match 补充;要逼近真 BM25 把饱和/长度归一预烘进 tf 值即可(v1 先 raw tf,后续再说)。
4.4 检索(Query API + prefetch + 融合)#
client.query_points(
"globex_items",
prefetch=[
Prefetch(query=dense_request_vec, using="dense", limit=k, filter=plat_filter),
Prefetch(query=SparseVector(...query tokens...), using="bm25", limit=k, filter=plat_filter),
],
query=FusionQuery(fusion=Fusion.DBSF), # 起步 DBSF;有 ESCI ground truth 再换 formula 线性 α/β
limit=top_k, with_payload=True,
)python- 🔴 spike 抓到的坑:payload 过滤必须放进每条
Prefetch(filter=...),放顶层query_filter在融合查询下静默失效(实测 shopee/shein 漏出)。item_search(platform=X)据此实现。 - 分库口径:单 collection + platform payload 过滤。单平台=带 filter;
all=不带 filter 一次 hybrid。 IDF 全库统计(语料更稳)。fork 子 Agent 传 platform 即转成 filter,与 04-1「按平台分库」语义等价。 - 双通道保留:dense prefetch 用
fuse(query, user)融合向量(个性化在此通路);稀疏 prefetch 用 纯 query 词法(个性化不入 lexical)。
4.5 provenance#
保留 data/index/qdrant_manifest.json:{model, dim, fusion, points, counts, embed:"remote"}。
serve 启动校验 EMBED_MODEL 与 manifest.model 一致(dim 守卫拦不住「两个同 1024 维不同模型」)。
5. embedding 环节核对 + 限流#
✅ 已正确/Qdrant 自带(不用操心)#
- L2 归一:
encode_texts已归一;Qdrant COSINE 再自归一,幂等无害。 - 维度守卫:Qdrant collection 强校验向量 size。
- query/item 对称:BGE-M3 instruction-free,无需 query 前缀,当前
encode_query/encode_item同路径正确。 - 输入长度:bge-m3 上下文 8192 ≫ 我们的短文本。
☐ 待办#
- 描述清洗
clean_text(§3)+ 重跑清洗。emoji 占 28%,最大收益。 tail_category(3)+ 词/句边界截断;共享app/recall/text.py(建索引↔serve 一致)。data/index/qdrant_manifest.json记 model + serve 启动校验。_encode_remote加重试/退避(3 次指数退避)。- RPM/TPM 限流(见下)。
5.1 限流设计(RPM=2000 · TPM=500000)#
- 估算:单条 embed_text ~380 字符 ~100–130 token;4427 条 ≈ 45–56 万 token,~70 个 batch(64)。
- RPM 非约束:70 req ≪ 2000。TPM 是活约束:总量压在 500k 线上 → 全建约 1–1.5 分钟。
- 实现:
build_item_index串行 batch + 滑动 60s 窗口节流,同时盯 (requests, tokens) 两个计数; 发批前若加上本批会破 90% 阈值(≤450k tok/min 或 ≤1800 req/min)则 sleep 到窗口腾出。 token 估算用字符数/3保守上估(对英文偏高 → 安全)。 - 先
build_item_index amazon单平台冒烟:实测每批 token、观察有无 429,再放全量。
6. 执行步骤#
hybrid 检索接进
item_search与本计划同批落地(Qdrant 一次请求即融合,不像 Faiss 要单独写融合)。
- 依赖:
uv add qdrant-client ftfy。 app/utils/clean.py:clean_text(§3.1)+ 符号/标点集(§3.3);clean_platform用它清洗落盘description,detect_lang复用同结果。app/recall/text.py(新):tail_category+bm25_tokens(§2.3)+sparse_vector(tokens)。scripts/clean_platforms.py重跑 → 刷新products.jsonl+by_platform/*.jsonl。app/recall/qdrant_client.py(新):QdrantRecall(双后端 §4.2 + 建 collection §4.1 + upsert +search(dense_vec, query_text, top_k, platform)§4.4)。替换ann.py的AnnClient/get_ann_client。scripts/build_item_index.py:读 clean 表 →embed_text(tail3+边界截断+轻量归一)编码 dense +sparse_vector编码 bm25 → upsert Qdrant;写qdrant_manifest.json;编码加重试 + §5.1 限流;真 bge-m3。app/tools/item_search.py:dense 走fuse(query,user)、sparse 走 query 词法,调QdrantRecall.search。- 真编码重建:先
build_item_index.py amazon单跑(验连通/批/维度1024/限流),再全量 5 平台。 - 测试:
clean_text用例(emoji/实体/标签/全角/中英标点/半句/多语言不误删)+tail_category+ Qdrant:memory:端到端(upsert→hybrid→过滤,含 prefetch-内过滤)。test_recall.py解析器单测已指向clean.py。
验收#
- Qdrant
:memory:端到端:upsert + dense/稀疏 + DBSF 融合 + platform 过滤(放 Prefetch)通过。 qdrant_manifest.json:model=BAAI/bge-m3、dim=1024、points=4427、5 平台 counts。- 冒烟检索(
running shoes/ 中文 query)召回合理、过滤生效。 - 自动门 ruff/format/mypy/pytest 全绿(CONVENTIONS 三关)。
7. 影响与回滚#
- 产物全 gitignore(
data/platforms/clean/*、data/qdrant/*、qdrant_manifest.json),脚本复现。 - 清洗改
description落盘 → 干净表与索引都需重生成。 app/recall/ann.py(Faiss)退役:被qdrant_client.py取代;保留 git 历史,不留死代码。- 回滚:去
EMBED_MODEL退本地哈希回退(离线可跑);清洗逻辑可单独 revert + 重跑两脚本恢复。
8. Scope 边界(明确不动的)#
- OpenSearch RAG 知识库(M5)一行不动 —— 本轮只 items→Qdrant。
- 长期记忆(M7)不动 —— 本地/Redis,独立于向量栈。
- α/β 线性融合(Qdrant formula)、真 BM25 饱和/长度归一、量化、ESCI ground truth 调参 —— 均后续里程碑,不在本轮。