面试知识库

执行计划:召回层 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 + 进程内 BM25dense 能扩,但进程内 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#

顺序字段处理
1title轻量归一(§3.2)
2brand空跳过
3category取尾 3 层tail_category(cat,3),§2.3)
4description已重清洗(§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 RunningAthletic > 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)                         # 实体 &amp;→&  &nbsp;→\xa0  &#39;→'(早做,连带解转义标签)
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(落盘上限)
plaintext

strip_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 用于过滤)
plaintext

COSINE 下 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 ≫ 我们的短文本。

☐ 待办#

  1. 描述清洗 clean_text(§3)+ 重跑清洗。emoji 占 28%,最大收益。
  2. tail_category(3) + 词/句边界截断;共享 app/recall/text.py(建索引↔serve 一致)。
  3. data/index/qdrant_manifest.json 记 model + serve 启动校验。
  4. _encode_remote 加重试/退避(3 次指数退避)。
  5. 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 要单独写融合)。

  1. 依赖uv add qdrant-client ftfy
  2. app/utils/clean.pyclean_text(§3.1)+ 符号/标点集(§3.3);clean_platform 用它清洗落盘 descriptiondetect_lang 复用同结果。
  3. app/recall/text.py(新)tail_category + bm25_tokens(§2.3)+ sparse_vector(tokens)
  4. scripts/clean_platforms.py 重跑 → 刷新 products.jsonl + by_platform/*.jsonl
  5. app/recall/qdrant_client.py(新)QdrantRecall(双后端 §4.2 + 建 collection §4.1 + upsert + search(dense_vec, query_text, top_k, platform) §4.4)。替换 ann.pyAnnClient/get_ann_client
  6. scripts/build_item_index.py:读 clean 表 → embed_text(tail3+边界截断+轻量归一)编码 dense + sparse_vector 编码 bm25 → upsert Qdrant;写 qdrant_manifest.json;编码加重试 + §5.1 限流;真 bge-m3
  7. app/tools/item_search.py:dense 走 fuse(query,user)、sparse 走 query 词法,调 QdrantRecall.search
  8. 真编码重建:先 build_item_index.py amazon 单跑(验连通/批/维度1024/限流),再全量 5 平台。
  9. 测试clean_text 用例(emoji/实体/标签/全角/中英标点/半句/多语言不误删)+ tail_category + Qdrant :memory: 端到端(upsert→hybrid→过滤,含 prefetch-内过滤)。test_recall.py 解析器单测已指向 clean.py

验收#

  • Qdrant :memory: 端到端:upsert + dense/稀疏 + DBSF 融合 + platform 过滤(放 Prefetch)通过。
  • qdrant_manifest.jsonmodel=BAAI/bge-m3dim=1024points=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 调参 —— 均后续里程碑,不在本轮。