ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

把 1.2 万条问答压进 3MB:本地知识库的召回率实测与切片粒度取舍

把 1.2 万条问答压进 3MB:本地知识库的召回率实测与切片粒度取舍 背景与素材一台不联网的电脑1.2 万条问答对去年秋天接的活规模小得不像项目。一家做本地生活服务的小店客服 8 个人日均咨询 300 多条售前问价格规格售后问安装退换。要做的事很朴素把整理好的问答对放进本地程序客服打字问程序把合适的答复调出来人工确认后再发给客户。约束一点不朴素-不依赖云端客服电脑在内网没有外网出口授权也不允许把客户聊天记录送到外部接口计算必须在本地完成。-包体要小安装包控制在 8MB 以内超过就走内部审批一等两周。-检索要快体感线定在 200 毫秒从敲回车到答案出现超过它客服就开始用鼠标乱点。### 三条约束互相打架想准就要留足上下文、用表达能力强的向量想小就得降维、量化、把切片压扁想快就得少算、少读盘、少比对候选。三条约束是一个三角形按下任何一个角另外两个就弹起来。目标机器是店里那台四年前的桌面机四核处理器8GB 内存系统盘是 5400 转机械盘我在固态盘开发机上跑得快的东西搬过去就未必。交付物的拆法先说清安装包 5.8MB装程序、索引和压缩后的原文嵌入模型的权重 68MB放在离线介质里现场拷贝。受 8MB 约束的只有前者标题里的 3MB 说的是索引里向量那一块。四个验收指标要在一台机器上同时成立| 编号 | 指标 | 目标 | 实测 || — | — | — | — || M1 | 200 题评估集 Recall5 | ≥ 90% | 96.0% || M2 | 单次检索耗时含编码与重排 | ≤ 200ms | 107ms || M3 | 向量索引体积 | ≤ 3MB | 3.04MB || M4 | 冷启动到可检索 | ≤ 3s | 1.9s |素材分三块合并 12050 条清洗完 10000 条历史聊天记录整理 8600 条清洗到 7020 条占 70.2%问题是口语化、重复问法多商品资料 1900 条清洗到 1680 条占 16.8%是描述型长文没有明确问句售后口径 1550 条清洗到 1300 条占 13.0%条款式表述句子长、嵌套多。清洗删掉 1430 条重复条目和 620 条不足 8 字的碎片。### 长度分布中位数 87 字长尾拖得很长统计只用标准库。pythonimport jsonqas [json.loads(l) for l in open(qa_clean.jsonl, encodingutf-8)]lens sorted(len(x[question]) len(x[answer]) for x in qas)n, total len(lens), sum(lens)pct lambda p: lens[int(n * p) - 1]print(条数, n, 总字符, total)print(均值, total // n, 中位数, pct(0.50))print(p75, pct(0.75), p90, pct(0.90), p99, pct(0.99))# 条数 10000 总字符 1123400# 均值 112 中位数 87# p75 154 p90 268 p99 640# 下限 9 上限 1180总字符 112.34 万均值 112 字中位数 87 字p90 是 268 字。下限那条只有 9 个字——“能不能开发票”上限那条 1180 字是售后纠纷处理流程分了三层条件、七个步骤。中文在 UTF-8 下平均 3 字节112.34 万字符就是 3.37MB这个数字当场提醒我原文要是不压缩光文本就把 3MB 预算吃干净了向量没地方放。| 长度区间 | 条数 | 占比 || — | — | — || 9 到 50 字 | 2350 | 23.5% || 51 到 150 字 | 4980 | 49.8% || 151 到 300 字 | 1870 | 18.7% || 301 到 600 字 | 640 | 6.4% || 601 到 1180 字 | 160 | 1.6% |7.3 成的条目在 150 字以内1.6% 的长条目却贡献了 12% 的字符量。这种分布决定了切片必须分层这是后面翻车的伏笔。图切片粒度、量化压缩与混合检索的取舍对照## 切片粒度对照实验三种切法200 个真实问题见分晓切片粒度是检索系统的地基也是我踩坑集中的地方。我不信惯例切 512 个词元这类说法那些惯例的语料是长文档我手里是短问答对。评估集是我从历史聊天记录里挑的 200 个真实问题覆盖售前、售后、物流、开票四类。### 三种切法与 200 题对照整条问答对做一片长条目也不切10000 条对应 10000 片。按句切用中文标点分隔过短的句子向前合并每片不低于 20 字pythonimport reSPLIT_RE re.compile(r[。\n])def split_by_sentence(text, min_len20): parts [s.strip() for s in SPLIT_RE.split(text) if s.strip()] merged, buf [], for p in parts: buf buf p 。 if buf else p 。 if len(buf) min_len: merged.append(buf) buf if buf: merged[-1] merged[-1] buf if merged else buf return merged固定窗口则是 200 字一片、重叠 50 字。三种切法的规模差得很远整条 10000 片平均 112 字按句 18760 片平均 60 字窗口 16480 片平均 178 字。窗口方案的总字符 293.46 万是整条方案的 2.6 倍——多出来的全是那 50 字重叠总共 181 万字符换算成向量存储是实打实的成本。口径统一用 Recall5正确来源出现在前 5 条里算命中检索用同一套编码方案只换切片方式| 方案 | Recall1 | Recall3 | Recall5 | 每题平均切片数 | 单次检索耗时 || — | — | — | — | — | — || 整条 | 61.0% | 79.5% | 86.0% | 1.0 | 34ms || 按句 | 55.5% | 72.0% | 78.0% | 1.9 | 51ms || 窗口 | 68.5% | 87.0% | 93.0% | 1.6 | 68ms |我原本坚信切得细、语义单元小、向量表达更纯按句切是我当时看好的方案实测它是三者里偏弱的一个Recall5 比整条低 8 个百分点。拿一个脱敏改写的例子原条目是保修期是两年那第二年上门维修要收费吗答案是两年内整机保修第二年上门只收上门费 30 元配件不收。按句切之后变成五片评估问题问的是第二年上门维修收钱吗命中片是第二年上门只收上门费 30 元可这一片里没有保修这个词22 字的短句能承载的语义信息太少相似度被一堆同样短的噪声片挤下去整条 60 字一起编码时“保修期”“上门”“收费三个概念在同一个向量里匹配度明显更高。分长度再看交叉点就出来了9 到 150 字的条目上整条 89.1%、按句 82.6%151 到 300 字上 86.5% 对 79.0%301 到 1180 字上整条掉到 58.0%按句还有 71.0%。细粒度在长条目上赢粗粒度在短条目上赢交叉点落在 150 到 300 字之间。反过来切太粗也有问题窗口方案把那条 1180 字的售后流程切成 7 片其中 5 片开头都是若属下列情形之一”语义高度雷同互相挤占前 5 名的名额真正的答案片排到第 6 位。问题不在切得粗还是细在于用一种尺度去处理长度差 100 倍的语料。### 混合粒度分档线是扫出来的不是拍的短条目整条入索引长条目按句拆拆出来的片带父条目 id命中子片就把父条目的完整答案拼回去给客服看pythondef chunk_qa(qa, thresh260): text qa[question] \n qa[answer] if len(text) thresh: return [{text: text, qa_id: qa[id], kind: whole}] pieces split_by_sentence(text) if len(pieces) 2: return [{text: text, qa_id: qa[id], kind: whole}] return [{text: p, qa_id: qa[id], kind: child, parent: qa[id]} for p in pieces]检索用短片保证精度展示用整条保证信息完整。阈值扫过 7 档120 字给 80.5% 召回、180 字给 85.0%、220 字给 89.5%、260 字给 93.5%、320 字给 92.5%、400 字给 91.0%、600 字给 89.0%260 附近是个平顶。选 260 而不是 320多花 1760 片换 1 个百分点召回这 1760 片在 3MB 预算里大约值 40KB。定稿 15860 片平均 71 字。结论写进了设计文档分档线应该由语料自身的长度分布决定而不是由词元数决定。## 向量化的成本账3MB 预算怎么一分一分挤出来切片定稿之后15860 条向量的体积成了绕不过去的算术题。### 先算不压缩的账再决定降到多少维编码器输出的维度是 512。不压缩的账很好算15860 乘 512 乘 4 字节等于 3248 万字节也就是 32.48MB超过 3MB 上限 10 倍。为给元数据和倒排索引留余地我给向量留的预算是 2.5MB。不压缩这条路直接走不通。降维和精度我分开试先看降到多少维会开始掉分。做法是对 512 维输出做一次固定的随机正交投影不需要额外保存统计量| 维度 | 单向量字节按 int8 | 全量体积 | Recall5 | 单次编码耗时 || — | — | — | — | — || 512 | 512 | 8.12MB | 93.5% | 11.2ms || 384 | 384 | 6.09MB | 93.5% | 10.4ms || 256 | 256 | 4.06MB | 93.0% | 9.1ms || 192 | 192 | 3.05MB | 91.5% | 8.6ms || 128 | 128 | 2.03MB | 87.0% | 8.1ms |512 维降到 256 维只掉 0.5 个百分点。原因后来想明白了这份语料的语义多样性天生就低。10000 条问答挤在价格、规格、物流、售后、开票五个话题里彼此交叉严重主方向的能量高度集中前 256 个维度已经装下 97% 左右的信息量。换成跨领域语料这个数字不会有这么好看。### 量化取舍int8 是甜蜜点二值化是悬崖标量量化逐维找范围线性映射到 0 到 255pythondef quantize(mat, dims256): mat 的行是向量返回量化字节串和每维的 scale、offset。 lo [min(row[d] for row in mat) for d in range(dims)] hi [max(row[d] for row in mat) for d in range(dims)] scale [(h - l) / 255.0 or 1e-8 for l, h in zip(lo, hi)] q [bytes(int((row[d] - lo[d]) / scale[d] 0.5) for d in range(dims)) for row in mat] return q, scale, lo隐蔽的坑在这逐维独立量化会改变向量之间的夹角和长度关系而余弦相似度对这两者都敏感。补救办法是把量化后的向量重新归一化代价是 256 维的平方和开方实测 0.02ms。不加这一步Recall5 会从 93.0% 掉到 88.5%。| 精度与维度 | 单向量字节 | 全量体积 | Recall5 | 相对 float32 的损失 || — | — | — | — | — || float32 512 维 | 2048 | 32.48MB | 93.5% | —— || float16 512 维 | 1024 | 16.24MB | 93.5% | 0.0 || int8 512 维 | 512 | 8.12MB | 93.0% | -0.5 || int8 256 维 | 256 | 4.06MB | 93.0% | -0.5 || int8 192 维 | 192 | 3.05MB | 91.5% | -2.0 || int8 128 维 | 128 | 2.03MB | 87.0% | -6.5 || 二值化 256 位 | 32 | 0.51MB | 74.5% | -19.0 || 乘积量化 64 段 | 64 | 1.02MB | 88.0% | -5.5 |二值化掉得太狠19 个百分点不能接受。乘积量化好一些88.0%体积只有 int8 的四分之一如果 3MB 是硬约束而 15860 片必须全放进去我会选它。不过差的这 1.06MB可以从别的地方省出来不必牺牲向量精度。### 压进 3MB 的路径换存法比换精度管用定稿的向量是 int8 加 210 维截断靠三件事把 4.06MB 压到 1.37MB。把重叠砍掉长条目按句切、不再做窗口重叠重复字符从 181 万降到 24.6 万切片从 16480 降到 15860。把能量贡献低的维度截掉按主方向的能量贡献排序后把贡献低于 0.05% 的维度去掉210 维就能保住 92.5% 的召回省下 0.73MB。对量化后的字节做差分再压缩pythonimport zlibdef pack_vectors(qrows): out bytearray() for row in qrows: prev 0 for b in row: d (b - prev) 0xFF out.append(d if d 128 else d - 128) # 折成有符号偏移 prev b return zlib.compress(bytes(out), 9)相邻维度的量化值因为语料同质变化很小差值集中在 -8 到 8 之间差分加压缩之后向量主体从 3.33MB 降到 1.37MB。这一步有人会说不划算因为解压要花时间实测解压 15860 条 210 维向量 240ms但这是一次性的程序启动时解压进内存之后检索用的就是内存里的裸数组逐次检索耗时没有变化。240ms 换 1.96MB我认为很划算。| 组成部分 | 压缩前 | 压缩后 | 说明 || — | — | — | — || 向量 15860 × 210 | 3.33MB | 1.37MB | int8 加差分加压缩 || 切片原文 112.34 万字符 | 3.37MB | 0.95MB | 压缩比约 3.55 || 关键词倒排索引 | 1.86MB | 0.50MB | 词项加差值编码 || 元数据 | 0.41MB | 0.16MB | 定长加字典 || 校验与目录表 | 0.06MB | 0.06MB | 不压缩 || 合计 | 9.03MB | 3.04MB | 超预算 1.3%接受 |存储格式顺手也对照过同一批向量JSON 数组文本 68.4MB、加载 2.8 秒CSV 文本 51.5MB、1.9 秒裸二进制打包 3.33MB、0.21 秒。很多人喊向量太大装不下问题出在存法上不在维度或量化上。## 混合检索向量救不了短查询和型号数字切片和量化都定下来之后Recall5 是 93.0%。但剩下那 7% 的漏检有一个非常集中的特征。我把漏检的 14 道题抄在便签上贴了三天发现它们几乎全是同一类。### 纯向量检索栽的两个跟头跟头一短查询。客服常打的不是完整问句是三到六个字。“运费多少”“能开专票吗”“几天到”。这类查询编码出来的向量特别糊五个字里没有足够上下文让编码器定位话题。实测长度不超过 7 个字的 32 道题Recall5 只有 71.0%长度超过 15 个字的查询Recall5 是 95.8%。跟头二型号和数字。语料里有大量型号与规格比如某系列 3 号规格 220 伏 1500 瓦。用户问1500 瓦的多少钱向量检索返回的是 1200 瓦和 2000 瓦的条目——“瓦数这个语义位置一样向量几乎相同编码器并不真的读懂了 1500 和 1200 的差别。我测了 40 道带型号或数字的题纯向量 Recall5 只有 62.5%。这是向量检索的结构性短板调参修不了。两类漏检加起来占全部漏检的 78% 左右另外 22% 是语料本身就没有答案。### 关键词这一路手写的简化打分与分词处理向量管语义关键词管精确。关键词这一路我手写了一个简化实现只用标准库。pythonimport mathfrom collections import defaultdictclass BM25: def __init__(self, docs, k11.5, b0.75): self.k1, self.b k1, b self.docs docs # list[list[str]]已分好词 self.N len(docs) self.avgdl sum(len(d) for d in docs) / self.N self.tf [defaultdict(int) for _ in docs] df defaultdict(int) for i, d in enumerate(docs): for w in d: self.tf[i][w] 1 for w in set(d): df[w] 1 self.idf {w: math.log(1 (self.N - c 0.5) / (c 0.5)) for w, c in df.items()} def score(self, terms, i): s, dl 0.0, len(self.docs[i]) for w in terms: if w not in self.idf: continue f self.tf[i].get(w, 0) if not f: continue s self.idf[w] * f * (self.k1 1) / ( f self.k1 * (1 - self.b self.b * dl / self.avgdl)) return s分词是整条链路里我花时间很多、收获也实在的部分。中文没有空格我起初用二元字组凑合拿运费多少举例二元组切出运费”“费多”“多少”“费多是纯噪声。后来补了三件事一份 1400 词的自定义词典把型号前缀、规格单位、业务口语全塞进去随索引打包占 46KB数字单独成词并把1500 瓦这类数字加单位的组合额外作为一个整体词项停用词表只留 88 个词我试过一张 200 词的表Recall5 掉了 1.5 个百分点因为多久”“几天”“能否这类词在问答语料里都是有效信号。### 权重怎么定一次网格搜索和它的曲线两路分数不同尺度没法直接相加。我先试加权归一化融合再试只按排名融合pythondef fuse(vec, kw, alpha): 两个 dict: id - score各自归一化后再加权。 def norm(d): if not d: return {} lo, hi min(d.values()), max(d.values()) if hi - lo 1e-9: return {k: 1.0 for k in d} return {k: (v - lo) / (hi - lo) for k, v in d.items()} a, b norm(vec), norm(kw) ids set(a) | set(b) return {i: alpha * a.get(i, 0.0) (1 - alpha) * b.get(i, 0.0) for i in ids}def rrf(vec_ranked, kw_ranked, k60): 按排名倒数求和不用归一化也不用调权重。 score defaultdict(float) for ranked in (vec_ranked, kw_ranked): for rank, doc_id in enumerate(ranked, start1): score[doc_id] 1.0 / (k rank) return sorted(score.items(), keylambda x: -x[1])alpha 是向量那一路的权重搜索范围 0 到 1步长 0.1两路融合的整体系列与两个短板子集的变化如下| alpha向量权重 | 加权融合 Recall5 | 排名融合 Recall5 | 短查询子集 | 型号数字子集 || — | — | — | — | — || 0.0纯关键词 | 76.5% | 76.5% | 90.5% | 92.5% || 0.2 | 87.0% | 88.0% | 88.0% | 90.0% || 0.3 | 91.5% | 92.0% | 87.5% | 88.5% || 0.4 | 94.5% | 95.0% | 86.5% | 86.0% || 0.5 | 96.0% | 96.5% | 85.0% | 83.5% || 0.6 | 96.5% | 96.0% | 81.0% | 79.0% || 0.7 | 95.5% | 95.0% | 77.5% | 74.5% || 0.8 | 94.5% | 93.5% | 74.0% | 70.5% || 0.9 | 94.0% | 92.5% | 71.5% | 66.5% || 1.0纯向量 | 93.0% | 93.0% | 71.0% | 62.5% |alpha 在 0.5 到 0.6 之间是个平顶整体到 96.0% 到 96.5%。但比整体分数更值得看的是另外两列短查询子集在 alpha 从 1.0 降到 0.0 的过程中从 71.0% 涨到 90.5%型号数字子集从 62.5% 涨到 92.5%。我定稿用 0.55这个点上两个子集分别是 84.0% 和 82.0%相对均衡没有哪个业务场景会塌陷。如果只盯整体 Recall5我会选 0.6然后客服问1500 瓦的时候继续被返回 1200 瓦的条目。排名融合的结果几乎和加权一样而且不用归一化、不用调权重形态更稳线上跑的是它——两个字典加一次遍历代码少不容易出错。| 指标 | 纯向量 | 纯关键词 | 融合alpha 0.55 | 融合相对纯向量 || — | — | — | — | — || 整体 Recall1 | 66.5% | 44.5% | 74.5% | 8.0 || 整体 Recall5 | 93.0% | 76.5% | 96.0% | 3.0 || 短查询 Recall5 | 71.0% | 90.5% | 85.0% | 14.0 || 型号数字 Recall5 | 62.5% | 92.5% | 83.5% | 21.0 || 单次检索耗时 | 45ms | 12ms | 58ms | 13ms |多花 13 毫秒换 3 个百分点整体召回、21 个百分点型号类召回。如果时间预算只有 50 毫秒那把关键词那一路做成数字与型号必须命中的硬过滤而不是完整打分——后者只用 6 到 8 毫秒能拿回一半的收益。## 命中率与延迟的取舍曲线top-k 取几重排值不值top-k 看起来能拍脑袋定实际不能。它被三方拉扯k 大了召回涨但耗时涨、候选变长信噪比变差k 小了响应快但漏检多。### k 从 1 扫到 20边际收益在第 5 条之后断崖在融合检索基础上把 k 扫一遍耗时是端到端的含编码、两路召回和融合| k | Recallk | 相对 k1 的提升 | 单次检索耗时 | 候选平均字数 | 噪声条目占比 || — | — | — | — | — | — || 1 | 74.5% | —— | 41ms | 71 字 | 0% || 3 | 92.0% | 17.5 | 51ms | 213 字 | 26% || 5 | 96.0% | 21.5 | 58ms | 355 字 | 38% || 8 | 96.5% | 22.0 | 66ms | 568 字 | 47% || 10 | 97.5% | 23.0 | 74ms | 710 字 | 55% || 15 | 98.0% | 23.5 | 91ms | 1065 字 | 62% || 20 | 98.5% | 24.0 | 106ms | 1420 字 | 68% |k 从 1 到 5 拿到 21.5 个百分点从 5 到 20 只多拿 2.5 个百分点耗时却涨了 48 毫秒。拐点在 5 到 8 之间。线上值设成 k5理由不是召回是交互客服一次能舒服看完的候选是 3 到 5 条超过 5 条就要滚动而滚动会让客服放弃候选列表、直接改用手工搜索。k 的取值应该由界面的呈现能力决定而不是由召回率的第 3 位小数决定。k5 时噪声条目占 38%这 38% 换来的是漏检从 8% 降到 4%我试过降到 3客服反馈有时候要翻好几次才找到”于是又调回 5。### 重排的收益与它在机械盘上的代价融合之后我加了一级重排把候选片和查询拼在一起逐对打分取高分排前面。它不是神经网络是一组可解释特征的加权权重由人工标定的 240 组排序样本拟合。pythondef rerank(pairs, w): pairs 是 (query, chunk) 列表w 是特征权重。 out [] for q, c in pairs: f { num: overlap_numbers(q, c), # 数字与型号重合度 cov: cover_ratio(q, c), # 查询词覆盖率 vec: vec_score(q, c), # 向量分 len: len_penalty(c, ideal90), # 长度惩罚 pos: position_penalty(q, c), # 命中词位置 } s (w[num] * f[num] w[cov] * f[cov] w[vec] * f[vec] - w[len] * f[len] - w[pos] * f[pos]) out.append((s, c)) return [c for _, c in sorted(out, keylambda x: -x[0])]| 配置 | Recall1 | Recall5 | 端到端耗时固态盘 | 端到端耗时机械盘 || — | — | — | — | — || 融合后直接用 | 74.5% | 96.0% | 58ms | 107ms || 融合加重排 top-10 | 86.0% | 96.5% | 82ms | 148ms || 融合加重排 top-20 | 88.5% | 97.0% | 92ms | 171ms || 融合加重排 top-50 | 89.0% | 97.0% | 129ms | 254ms |重排把 Recall1 从 74.5% 提到 88.5%涨 14 个百分点代价 34 毫秒它改善的是客服看一眼就对了的概率不是第 5 条里有答案这种安慰性指标。但重排要读原文算数字重合与覆盖率都得看文本机械盘上到 171 毫秒已经贴近 200 毫秒的体感线。机械盘机器上用 top-10固态盘机器上用 top-20 加更重的特征权重。### 不同硬件档位的配置建议| 硬件档位 | 切片阈值 | 维度与精度 | top-k | 重排 | 融合 | 预期耗时 | 预期 Recall5 || — | — | — | — | — | — | — | — || 双核 4GB 机械盘 | 320 字 | 192 维 int8 | 3 | 关闭 | 加权 | 68ms | 91.5% || 四核 8GB 机械盘 | 260 字 | 210 维 int8 | 5 | top-10 | 排名融合 | 148ms | 96.5% || 四核 8GB 固态盘 | 260 字 | 210 维 int8 | 5 | top-20 | 排名融合 | 92ms | 97.0% || 八核 16GB 固态盘 | 220 字 | 256 维 int8 | 5 | top-20 | 排名融合 | 61ms | 97.5% || 八核 16GB 固态盘预算放宽 | 180 字 | 512 维 float16 | 10 | top-50 | 排名融合 | 96ms | 98.5% |这张表是交付时附带的东西比代码本身更受欢迎——对方换机器时不用再问我要参数。## 本地推理落地不联网带来的额外成本嵌入模型的选型我刻意不写具体名字这类东西半年换一批写进文章就是给未来的自己挖坑只讲三个量和它们怎么互相拉扯。### 选型只看三个量权重体积、编码耗时、输出维度| 档位 | 权重体积 | 输出维度 | 单次编码耗时 | 200 题 Recall5 | 常驻内存 || — | — | — | — | — | — || 很小 | 22MB | 256 | 4.1ms | 83.5% | 96MB || 小 | 68MB | 512 | 9.8ms | 93.0% | 152MB || 中 | 190MB | 768 | 27.5ms | 94.5% | 380MB || 大 | 420MB | 1024 | 61.0ms | 95.0% | 860MB |三个量正相关表达能力越强参数越多耗时越长维度越高后两项都在为 3MB 的索引预算付账。逻辑不是挑好的而是找到刚好够用的那条线。从 190MB 到 420MB权重翻了一倍多、耗时翻了一倍召回只涨 0.5 个百分点从 22MB 到 68MB 那一步涨了 9.5 个百分点值钱的是这一步。维度同样是硬约束512 维量化后 4.06MB768 维就是 6.09MB直接冲出预算。选型时输出维度必须先卡死不能等模型选完再想办法压。权重 68MB 不进安装包还有第二个好处更新知识库不用重新分发模型客服那边的动作从装一个 5.8MB 的包变成替换一个 3.04MB 的索引文件。### 冷启动三段耗时以及哪次优化被我撤掉了| 阶段 | 固态盘 | 机械盘 | 说明 || — | — | — | — || 读权重、初始化运行时 | 0.42s | 0.81s | 无法并行必须串行等 || 解压索引三部分 | 0.19s | 0.47s | 向量 240ms、原文 150ms、倒排 80ms || 重建内存索引结构 | 0.09s | 0.21s | 逐条做差分解码 || 合计到可检索 | 0.70s | 1.49s | 界面首屏另算 0.41s |机械盘上 1.49 秒加首屏 0.41 秒体感 1.9 秒在 3 秒目标线内。优化做了两件事第三件后来撤掉了。合并文件索引从 5 个文件改成 1 个文件加尾部目录表机械盘读盘从 0.83 秒降到 0.47 秒。首屏优先先画界面索引在后台线程加载输入框在完成前禁用并显示加载状态感知等待从 1.9 秒降到看起来的 0.4 秒。撤掉的是常驻进程预加载。那台机器 8GB 内存是共享的常驻 152MB 在月初开单高峰被系统换出去预加载占的便宜全被页交换吃回去还多花一次冷启动的时间。改回15 分钟不活动就退出、下次冷启之后反而稳定。在内存紧张的机器上预热不是优化是把一次确定的成本换成一次不确定的代价。## 三次失败记录每一次都是被自己推翻的前面几节写的是结果这一节写过程。三次翻车我都留着当时的记录它们比成功的版本更能说明取舍的边界在哪。### 失败一整条入索引长文档把短问题挤掉第一版我偷懒全量整条切片10000 片Recall5 是 86.0%我一度觉得可以交付。直到客服反馈问运费给我返回一整页退换条款。看检索日志问题很清楚那 160 条长条目在向量空间里是话题大杂烩跟很多话题都有中等相似度几乎在每个查询里都能挤进前 5 名。返回的 5 条里平均有 2.3 条是那 160 条长条目而它们只占全部条目的 1.6%过度代表达到 144 倍。把长条目从候选里排除短条目召回立刻从 62.0% 跳到 89.5%——固定取前 5 名名额是零和的长条目不只是自己排得靠前还把本该排前面的短条目顶下去了。改成分档切片之后返回的 5 条里长条目平均 0.4 条整体 Recall5 从 86.0% 涨到 93.5%。索引里条目长度严重不均时向量检索会系统性偏向最长的那一批而它对指标的表观损害不大、对体验的损害极大。### 失败二量化压得太狠数字和型号几乎全丢看到 32.48MB 的时候我心态有点急直接从 32 位浮点跳到二值化256 位0.51MB体积一步到位。跑完 200 题我以为脚本写错了Recall5 只有 74.5%比 32 位浮点掉 19 个百分点。把漏检按类型分开看分布很不均匀普通语义类问题从 94% 掉到 88%涉及型号与数字的问题从 92% 掉到 41%。二值化只保留符号方向“1500 瓦和1200 瓦的向量符号位几乎完全一样被压成同一个近似点这在数学上必然不是实现问题。我原先假设召回损失会均匀分布在所有查询上”这个假设错得很离谱。修法是三步回退二值化退回 8 位整数74.5% 涨到 93.0%512 维降到 256 维93.0% 不变再靠差分压缩和维度截断把体积拿回来4.06MB 降到 1.37MB。路径是弯的我以为要靠降精度省体积实际真正省体积的是压缩存储和去冗余不是丢信息。### 失败三缓存没失效更新后还返回旧答案这个 bug 很笨也难发现因为它不崩不报错只是慢慢地回答得不对。知识库每周更新一次程序启动时把索引读进内存我又加了一层结果缓存键是查询文本值是切片 id 列表容量 512 条索引更新后程序会重新加载索引但缓存没有跟着失效。发现过程很偶然。三周后运营把一条售后口径从上门费 30 元改成50 元改完第二天客服还是报 30 元被投诉了一次。我拿原句去测返回新答案把问句换一种说法“第二年上门要收钱吗”再测返回的还是 30 元。原因就是缓存键只用了查询字符串索引版本号虽然存在字段里读取时从没校验过。python# 出错缓存键不含索引版本def search(q): if q in cache: return cache[q] # 索引换了这里仍然命中旧结果 res do_search(q); cache[q] res return res# 修好版本号与指纹一起进键def search(q): key (index_version, fingerprint, q) if key in cache: return cache[key] res do_search(q); cache[key] res if len(cache) 512: for k in list(cache)[:256]: # 粗略淘汰丢掉早插入的一半 cache.pop(k, None) return res真正的修法不止把版本号塞进键还加了两条防线读缓存时比对索引文件的哈希指纹不一致就丢弃这一条防的是文件被替换但版本号忘了改——更新脚本确实漏改过一次每次命中都记一行日志带上版本号每周更新后扫一眼有没有旧版本号还在被命中。复测时更新索引后立刻用 6 种改写问法测同一条口径全部返回新答案命中率因为版本切换后有一段冷缓存从 34% 掉到 30% 上下稳定后回到 33%。这一课写了两句话进文档只要缓存对用户不可见失效 bug 就会以内容不对的形式暴露而不是以报错的形式暴露。以及任何会被替换的文件都值得在读取路径上验一次指纹因为总会有人忘了改版本号包括我自己。## 最终方案与实测数据汇总### 定稿配置每个维度选了什么为什么| 维度 | 定稿选择 | 定稿前试过 | 为什么这么定 || — | — | — | — || 切片粒度 | 长短分档阈值 260 字 | 整条、按句、窗口 200/50 | 短条目保上下文长条目保精度 || 切片总数 | 15860 片 | 10000 / 18760 / 16480 | 阈值扫描的平顶位置 || 向量维度 | 210 维按能量截断 | 512 / 384 / 256 / 192 / 128 | 256 维起再降就明显掉分 || 量化精度 | 8 位整数 | 32 位浮点、16 位浮点、二值化、乘积量化 | 二值化丢 19 个百分点不能接受 || 存储 | 裸二进制加差分压缩 | JSON 文本、CSV、十六进制 | 文本体积是二进制的 20.5 倍 || 检索 | 向量加关键词排名融合 | 纯向量、纯关键词、加权融合 | 补短查询与型号只花 13ms || top-k | 5 | 1 / 3 / 8 / 10 / 15 / 20 | 拐点在 5 到 8 之间还要适配界面 || 重排 | 开机械盘 top-10固态盘 top-20 | 关闭、top-50 | 召回涨 14 个百分点耗时涨 34ms || 指标 | 定稿前 | 定稿后 | 目标 | 结论 || — | — | — | — | — || 整体 Recall1 | 66.5% | 88.5% | 无 | 超出预期 || 整体 Recall5 | 86.0% | 97.0% | ≥ 90% | 达成 || 短查询 Recall5 | 71.0% | 85.0% | 无 | 可用 || 型号数字 Recall5 | 62.5% | 83.5% | 无 | 可用 || 单次检索耗时机械盘 | 107ms | 107ms | ≤ 200ms | 达成 || 索引体积 | 9.03MB | 3.04MB | ≤ 3MB | 超 1.3%接受 || 冷启动到可检索 | 1.9s | 1.9s | ≤ 3s | 达成 || 安装包体积 | 5.8MB | 5.8MB | ≤ 8MB | 达成 |现场数据只留了两个客服平均查找一条答复的耗时从 26 秒降到 9 秒抽测 60 次人工改写答案的比例从 41% 降到 18%。后者能降下来说明返回候选里能直接用的比例真的变高了。## 局限语料单一评估集是自己造的### 自造评估集会让召回率虚高那 200 道题是我从同一批聊天记录里挑的用的是同一套口语习惯和业务词编码器在索引时见过近似表达所以 97.0% 这个数天然带着乐观偏差。我做了个粗略校正从更早半年的记录里另挑 60 道题业务没变、口径有差异再让运营把 40 道题改写成没用过的问法重跑一遍。| 评估集 | 题数 | Recall5 | 相对同分布评估集 || — | — | — | — || 同分布评估集 | 200 | 97.0% | —— || 跨时间段评估集 | 60 | 89.5% | -7.5 || 运营改写过的问法 | 40 | 86.0% | -11.0 |掉 7.5 到 11 个百分点我认为真实场景下应该在 88% 到 92% 之间。任何只报告单一自造评估集召回率的方案都值得打折看包括我自己这一份。### 流程可以复用参数不能复用阈值 260 字来自 p90210 维来自能量贡献低于 0.05% 就砍这两个数都长在这份语料的分布上。换成条款、手册这类以长文档为主的语料p90 会跑到上千字260 立刻失效换成话题跨度大的语料前 210 维的能量占比会掉下去。语料里混了不到 0.3% 的英文术语我按普通词项处理没做跨语言验证系统是单轮检索不记上下文那这个呢这类承接式追问 Recall5 只有 68% 上下补救办法是提示客服把问题写完整而不是改检索算法。## 收尾难点在语料工程不在模型这个项目从头到尾没换过编码器。真正决定效果的是花两天数语料的长度分布、把切片当成需要做对照实验的决策、在体积与召回与延迟之间做显式权衡、接受召回率只能到 92% 左右然后把它交给界面和人工确认。三个月里被推翻的假设有四五个印象深的有三个以为切细更好以为压缩损失会均匀分布以为缓存不是问题。每次翻车的共同点都是我用了一个听起来合理的假设而没做那个只要半天就能做完的对照实验。如果手上也有一个不能上云、包体有限、还得快的知识库需求建议先把语料数清楚、把评估集做出来再去选技术组件——顺序反过来后面的每次优化都会变成盲调。## 相关实现文中这套分档切片、量化压缩、双路融合加轻量重排的做法后来在一个本地部署的客服应答工具里跑了起来它把商品资料和售后口径整理成知识库在一台普通桌面电脑上完成编码、检索与应答索引和原文都留在本机和文中不依赖云端、包体受限、检索要快这三条约束是同一套区别是它的语料更新更频繁所以缓存那一节的指纹校验成了它的常驻做法。同方向的实现可以看这个dingdang.asia/microai/
返回列表