很多人做企业级问答系统,前期流程走得都挺顺,分块、检索、Prompt 都调得有模有样,结果一上线就被用户反馈“答非所问”。我自己踩过最典型的坑就是:用户问“今年医保报销比例是多少”,知识库里明明有一篇文档写得很详细,但系统就是召不回,因为它跟“报销比例”这几个字长得不像。这背后缺的,就是语义层面的匹配能力——也就是 Embedding 与向量化。
这一章是“从零到一搭建企业级智能问答系统”系列的实战章,重点聊清楚一件事:怎么把文本变成机器能比较语义的向量,并且让这条向量化管线在企业级场景里真的跑得稳。适合正在做 RAG、智能客服、知识库问答的团队参考,不管是已经选了向量数据库,还是正准备选型,都能从这章里找到可落地的方案。
1. Embedding 与向量化:检索效果的分水岭
1.1 问答系统的召回链路为什么绕不开向量检索
先还原一下知识库问答的主链路:用户问题进来,系统先去知识库召回候选文档,再把候选文档和问题一起丢给大模型生成答案。很多团队把大部分精力花在 Prompt 和模型调优上,却忽略了召回这一步。但实际上,生成模型再强,召回的文档不对,它也只能一本正经地胡说八道。
传统的关键词检索(比如 BM25)是一种字面匹配:query 里出现的词,和文档里出现的词重叠越多,得分越高。这在结构化程度高、术语统一的场景下够用,但到了自然语言表达千变万化的企业场景,就彻底不够了。同一个意思,用户可能说“报销比例”,文档里写“支付比例”“费用承担比例”“自付比例”,字面上几乎不沾边,语义上却是一回事。要解决这类问题,就得把文本映射到一个向量空间里,让语义相近的句子在空间里距离更近——这一步就是 Embedding,把文本转成固定维度的稠密向量,也叫向量化。
在企业级问答系统里,向量化不是一个可选项,而是召回链路里的必经之路。哪怕你最终做混合检索,底层也一定有一路是向量召回,否则系统对“换个说法提问”的包容度会非常差。
1.2 一句话怎么变成一串数字:Embedding 的直观理解
很多人第一次接触 Embedding,会觉得“向量”这个概念很抽象。我习惯用一个生活化的类比:想象你在给每个文本做“体检报告”,报告里有几千个指标,每个指标对应空间里的一个维度。比如某个维度可能捕捉了“这句话是否涉及金钱”,另一个维度捕捉了“是不是在讲医疗政策”,还有大量维度根本无法用语言解释,但它们组合在一起,就能把一段话的位置固定下来。
AI 模型在训练时,会把海量的文本对、上下文关系压缩进这些维度里:意思相近的句子,在这套“体检报告”上的指标长得像;意思南辕北辙的句子,指标差异就大。所以,要判断两句话语义是否相近,只要比较两个向量的距离就好。常用的距离度量有余弦相似度、内积、欧氏距离等,后面我会专门讲怎么选。
这里想特别提一下多模态向量化的趋势。Google 在 2025 年开源的 SigLIP2,就是一个典型的“视觉-文本联合向量模型”,它能把图片和文本映射到同一个向量空间。对问答系统来说,这意味着知识库里的截图、产品图、扫描件,终于可以和文字一起统一检索了。过去我们做向量化只处理纯文本,现在多模态 Embedding 开始进入主流视野,企业知识库的形态也会因此发生变化。
2. 模型选型:Embedding 模型排行只是参考,别当唯一标准
2.1 主流的 Embedding 模型与 SigLIP2 带来的新变量
目前业界可选的 Embedding 模型非常多,从开源到商业 API 都有成熟的选项。我在实际项目里经常拿来对比的几个如下:
| 模型 | 类型 | 常见维度 | 中文支持 | 部署方式 | 适用场景 |
|---|---|---|---|---|---|
| BGE-M3 | 开源 | 1024 | 好 | 本地私有化 | 多语言、企业私有化部署 |
| M3E-base | 开源 | 768 | 好 | 本地私有化 | 中文知识库,中小规模 |
| text-embedding-3-small | 商业 API | 1536 | 中等 | API 调用 | 快速验证、多语混合场景 |
| text-embedding-v3 | 商业 API/本地 | 1024/1536 | 好 | API 或私有化 | 中文业务、可控成本 |
| SigLIP2 | 开源 | 可配置 | 多模态 | 本地私有化 | 图文联合检索、多模态知识库 |
从上表能看出,开源模型和商业模型的选择,核心矛盾通常不在效果,而在“数据能不能出域”。企业内部的知识库往往涉及合同、财务、研发文档,很多公司政策明确规定数据不能发给外部 API,这就直接决定了你必须选本地可部署的开源模型。SigLIP2 这类模型开源的意义就在于此——它把多模态向量化能力也带到了私有化场景。
排行榜和 MTEB/C-MTEB 榜单不是不能看,但要学会看门道。榜单上第一名往往只比第十名高出零点几个点,这个差距在通用评测集上可能有意义,落到你自己领域的数据上,差距可能直接反转。我见过不止一次:某个模型在榜单上排名靠前,但在客户的法律文书检索里表现一塌糊涂,反而是另一个中文专项模型效果好得多。
2.2 企业选型真正要抠的四个约束
抛开效果不谈,企业选型通常被四个硬性约束卡死:
第一是部署方式。如果数据必须私有化,那就只能在开源模型里挑,并准备好 GPU 或者 CPU 推理资源。第二是中文质量。很多英文训练为主的模型,对中文长尾表达支持很差,容易把“公积金”和“住房公基金”这种同义词映射到差异很大的位置。第三是维度成本。向量维度直接决定存储和计算成本,1024 维和 1536 维听起来差 50%,实际查询时的内存和带宽消耗也会相应放大。第四是维护成本。商业 API 升级版本、调整维度、变更价格,都可能影响线上链路;开源模型则要自己处理推理服务、并发扩容和版本迭代。
实操层面,我的建议是做一个二维决策矩阵:横轴是数据敏感度(能否出域),纵轴是预算和运维能力。敏感度高选开源本地部署,预算充足且数据可出域就用商业 API,两者之间可以考虑“本地开源模型 + 云端向量数据库”的组合。
2.3 先做小样本验收,再定模型
千万不要在没有验证的情况下,直接按排行榜定模型。我在项目里的做法是:从真实知识库里随机抽出 1000 条左右的分块文本,构造 50 个用户问题,每个问题标注好它的正确答案是哪些文档块,然后跑一次小规模召回评估,直接对比 Recall@5。
评估脚本本身不复杂:把问题和正确文档块都向量化,检索 Top5,看正确文档有没有出现;统计出现比例。这一步跑下来,通常一个下午就能出结果。我见过最典型的结果:某个通用模型在中英文混杂语料上 Recall@5 只有 0.62,换成中文专项模型直接到 0.81,这时候榜单排名还重要吗?不重要了,自己的数据说了算。
3. 向量化管线工程实现:从原始文档到可检索的向量库
3.1 清洗与分块:Embedding 的上限在输入质量
很多团队在向量化之前不做清洗,文档里的页眉页脚、重复表格、乱码符号全部塞给模型,结果向量里混入了大量噪音。文本清洗至少要做三件事:去页眉页脚、压缩空行和常见乱码、识别并丢弃无信息的表格碎片。
清洗之后是分块。分块策略直接决定检索粒度,这里没有银弹。我的默认做法是:先用结构分块,按 Markdown 标题、段落边界切开;如果某些段落太长,再按固定窗口做二次切分。窗口大小在选用模型时按 token 估算,而不是按字符数。中文场景下,一个汉字大约对应 0.6 到 1 个 token,我用 512 token 的 chunk size、64 token 的 overlap 作为起点,再根据检索效果微调。overlap 存在的意义是避免某个句子恰好被拦腰截断——一个句子的后半段和前半段拆到两个 chunk 里,两边都语义残缺,召回自然差。
这块工作看着基础,实际上对最终检索效果的影响,比模型换大换小还要明显。我自己就有过教训:早期清洗做得糙,embedding 模型也换了好几个,效果始终上不去,后来把文档里的表格碎片清洗干净,同一个模型 Recall@5 直接涨了 8 个点。
3.2 批量向量化的工程细节:并发、重试、断点续跑
数据量小的时候,循环逐条调用 embedding 接口没什么感觉;但知识库一旦上了几十万、上百万条分块,单线程串行就会慢到让人怀疑人生。批量向量化管线,我建议从一开始就按三个要求来设计:
- 并发控制:用线程池控制并发数,避免瞬间打满 API 配额或压垮本地推理服务。
- 失败重试:网络抖动、服务端限流是常态,必须做指数退避重试。
- 断点续跑:已经向量化的 chunk 要能跳过,不能在跑到一半挂掉后从头再来。
下面是我常用的一段伪代码骨架,逻辑可以直接套到任意远程或本地 embedding 服务上:
import hashlib import json import time from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path def load_chunks(path: str) -> list[dict]: # 每条 chunk 至少包含: id, text, doc_id, meta return json.loads(Path(path).read_text(encoding="utf-8")) def embed_text(text: str) -> list[float]: # 远程 API 或本地模型服务,统一返回向量 # 这里用抽象函数代替,实际接入时替换为对应 SDK 调用 return embedding_service.encode(text) def already_done(chunk_id: str, output_dir: Path) -> bool: marker = output_dir / f"{chunk_id}.json" return marker.exists() def embed_one(item: dict, output_dir: Path) -> dict: if already_done(item["id"], output_dir): return item last_err = None for attempt in range(4): try: vector = embed_text(item["text"]) record = {**item, "vector": vector} (output_dir / f"{item['id']}.json").write_text( json.dumps(record, ensure_ascii=False), encoding="utf-8" ) return record except Exception as e: last_err = e time.sleep(2 ** attempt) # 指数退避 raise RuntimeError(f"chunk {item['id']} failed: {last_err}") def run(chunks_path: str, output_dir: str, max_workers: int = 8): out = Path(output_dir) out.mkdir(parents=True, exist_ok=True) chunks = load_chunks(chunks_path) with ThreadPoolExecutor(max_workers=max_workers) as pool: futures = [pool.submit(embed_one, item, out) for item in chunks] for future in as_completed(futures): future.result() # 有异常会在这里抛出来这套设计的核心不是代码本身,而是“任务可恢复”。几十万条数据跑上几个小时很常见,任何意外中断都不该导致前功尽弃。
3.3 向量存储选型:从文件到真正的向量数据库
向量化之后,向量得存下来。量小的时候可以先用 JSON 存本地文件,所有向量加载进内存,用暴力计算相似度;但这只适合几千条以内的验证场景。到生产环境,我建议直接用向量数据库。选型主要看在你有多少数据、有没有现成的存储设施、团队愿意运维什么。
| 方案 | 适合规模 | 部署复杂度 | 典型场景 |
|---|---|---|---|
| 本地文件 + NumPy | 1 万以下 | 极低 | 原型验证、离线任务 |
| pgvector | 几十万到百万 | 低,复用现有 Postgres | 企业已有 PG、数据量中等 |
| Qdrant | 百万到千万 | 中 | 独立向量服务、Rust 高吞吐 |
| Milvus | 千万以上 | 高 | 大规模知识库、分布式需求 |
我个人经验:中小企业如果已经有 Postgres,可以先上 pgvector,减少一套新组件;等向量规模到了几百万,再评估迁移到 Qdrant 或 Milvus。相比引入一套全新基础设施,前期的复杂度代价通常更值得优先控制。
3.4 入库之后的第一件事:自检召回
很多人把数据灌进向量库就以为完事了,直到线上效果崩了才回头排查。我的习惯是入库后立刻跑一轮“已知答案检索”:拿几十个人工标注过的 Query,去向量库里检索,看正确文档排在第几位。如果正确结果排不进 Top5,那说明链路里肯定有环节出了问题——可能是清洗不到位、分块不合理、向量没归一化,也可能是相似度度量选错了。这一步花不了 30 分钟,却能避免把坏数据带到线上。
4. 检索精度优化:向量召回不是全部,混合检索才是常态
4.1 距离度量:余弦、内积、欧氏距离怎么选
向量之间的相似度计算,工程上最常用三种度量。余弦相似度看的是方向,不受向量长度影响,语义检索里最常用;内积则同时受方向和长度影响,但如果你在入库时已经对向量做了归一化,内积和余弦在数值上是等价的;欧氏距离走的是绝对距离路线,对向量的模长很敏感,不适合直接用在文本向量上。
实线上,我推荐一个组合:离线向量化时把所有向量归一化(除以模长),存库时直接存归一化后的向量,在线检索用内积。这样做有两个好处:第一,计算内积比计算余弦相似度少一步除法,在高并发下能省不少 CPU;第二,很多向量数据库对内积有指令级优化,性能比通用余弦计算更好。归一化代码就一行:v = v / np.linalg.norm(v),但这一步漏掉的团队不在少数。
需要提醒的是,如果你用了多个不同型号的 embedding 模型,绝对不能把各自产出的向量混进同一个索引。不同模型的向量空间根本不对齐,余弦相似度算出来毫无意义。这一点在后面踩坑部分我会再展开说。
4.2 混合检索 + Rerank:单靠向量会漏掉精确匹配
向量召回擅长语义相似,但有另一个毛病:对精确关键词不够敏感。用户搜“合同编号 CT-2024-011”,向量模型可能觉得这串编号跟文档里的“编号为 CT2024011 的合同”很接近,但也有可能因为编号被当成不重要的 token 而漏掉。这种场景下,传统 BM25 反而更可靠。所以企业级问答系统里,我几乎总是建议做混合检索:一路向量召回,一路 BM25 关键词召回,再把两路结果合并。
合并分数时,最容易犯的错误是把两路的分数直接相加。BM25 分数和向量相似度完全不在一个尺度上,直接相加等于让某一路主导。更稳的做法是用 RRF 倒排融合算法:对每一路给出候选排序,每个文档的融合得分是它在各路边排序名次的倒数之和,公式大致如下:
score(doc) = Σ_models 1 / (k + rank_model(doc))常数 k 一般取 60。RRF 的好处是不需要对齐不同模型的分数尺度,只关心排序名次,简单且鲁棒。
混合检索之后,通常还会挂一层 Rerank 精排:把两路召回合并后的 Top 50 个候选,用一个专门的 Rerank 模型(比如 cross-encoder 类模型)逐对计算“问题-文档”的相关性,再取 Top 3 到 Top 5 送给大模型。这一步能显著提升最终答案质量,代价是多一次模型推理。候选数量必须控制在几十条以内,不然延迟会很高。
4.3 元数据过滤:缩小检索空间的隐藏杠杆
向量检索在海量文档里全局找相似,听起来很强大,但也意味着更容易被无关领域的内容干扰。比如一个企业知识库里既有研发文档又有行政制度,用户问“报销流程”,结果召回了一堆技术方案里提到“流程”的段落。这时候,元数据过滤就派上用场了。
入库时给每个 chunk 打上 doc_id、所属部门、文档类型、更新日期等元数据;检索时,如果业务场景能确认用户问题属于某个范围(比如用户来自财务部门,或者当前会话选择了“行政制度”分类),就可以在向量检索前先做 filter,把搜索范围限制到对应标签下。在 Qdrant 和 Milvus 里都有成熟的 filter 机制。这个手段的收益往往不亚于换模型,但很容易被忽略。
4.4 向量检索的四个经典误区
做向量化实战这段时间,我总结出现频率最高的四个误区:
一是把超长文本直接丢给 embedding 模型。很多模型的输入有 token 上限,超过会被截断,语义信息严重损失。必须在分块阶段就控制长度,而不是依赖模型硬扛。
二是向量不归一化就存库。有些 SDK 返回的向量模长差异很大,直接做内积会让长向量主导结果,用余弦相似度又增加线上耗时。正确做法是入库前统一归一化。
三是全库只建一个索引,不做隔离。不同类型文档混在一个集合里,互相干扰,检索精度很难提升。合理做法是按业务域拆集合或加元数据标签。
四是没有评测集就上线。没有评测集,你就无法判断模型升级到底是变好还是变坏。哪怕是先整理 100 个历史问题作为种子评测集,也比全靠感觉强。
5. 企业级落地:成本、增量更新、监控与降级预案
5.1 成本估算:从维度到存储再到推理开销
向量化在企业级落地的第一道坎,往往不是技术而是预算。存储成本很好算:一条 1024 维的 float32 向量占 4KB 左右,100 万条分块就是约 4GB,加上向量索引(比如 HNSW)的额外开销和原始文档存储,实际占用通常要再翻一倍。如果把向量压成 float16,存储减半,精度损失在多数场景下可以忽略。
调用外部 API 的话,费用大头是 embedding 接口的 token 计费和向量数据库的托管费。内部评估时可以按“全量知识库 tokens = 总字符数 × 0.6 左右”粗算,再乘以单价。如果是本地部署开源 Embedding 模型,一张常规 GPU 就能扛住中等规模企业的索引构建和在线查询,瓶颈一般不在模型本身,而在向量数据库的查询并发。所以我的建议是:先以外包 API 跑通,数据量涨到一定规模后再评估本地化。
5.2 增量更新的坑:向量库没有“原地修改”
知识库是不断更新的,文档改版、下线、新增都不可避免。这里有个很容易踩的认知误区:以为向量库像数据库一样支持 update。实际上,向量数据库基本不支持“修改向量”,常见的做法是先把旧向量删掉,再插入新向量。
问题在于,如果代码里对旧 chunk 的定位不够精确,就会出现“旧文档删不干净、新文档又插入”的脏数据。我在项目里要求每条 chunk 必须携带 doc_id 字段,更新文档时先按 doc_id 删除所有关联 chunk,再重新分块、灌入新向量。删除和插入最好放在同一个事务语义里,避免删完没插成功导致知识库出现短暂空缺。这块逻辑不复杂,但一定要在需求阶段就排进去。
5.3 上线后盯三个指标
问答系统上线后,不能只看“有没有人用”,要盯住三个核心指标:
第一个是检索延迟,特别是 p95 延迟。从用户提问到召回结果返回,企业场景通常要求控制在 300ms 以内;如果超过 1s,交互体验就会明显变差。向量数据库的查询速度和 Rerank 的候选数量是最常见的延迟瓶颈。
第二个是召回质量,具体看 MRR 和 Recall@5。可以定期用离线评测集跑一遍,对比线上版本和上一次版本,防止某次模型升级或索引重建后效果悄然下滑。
第三个是空召回率。用户问了一个问题,检索结果 Top N 里没有任何疑似相关文档,这种“空召回”对问答体验打击极大。空召回率偏高时,要优先排查分块粒度、文档覆盖面和查询改写策略。
5.4 降级预案:向量服务挂了,问答不能跟着挂
再稳定的向量服务也保不齐哪天出故障。企业级系统必须有降级方案。我在实际架构里保留了传统关键词检索链路,当向量数据库或 Embedding 服务检测到超时/熔断时,请求自动切到 BM25 关键词检索,同时返回一个“当前检索能力受限”的提示,而不是让整个问答服务不可用。
另一个实用手段是热点缓存:对高频问题,把它的最终答案缓存起来,命中缓存直接返回,既降低 embedding 压力,又能在故障时保底。我见过不少团队把缓存只当作性能优化手段,其实它也是高可用的重要防线。
6. 踩过的坑与个人体会
6.1 三个让我印象深刻的坑
第一个坑是静默截断。有次我们把一批长技术文档直接送进一个商业 embedding API,API 对超过 8192 token 的输入会静默截断而不是报错,结果文档后半部分的语义全部丢失,检索效果莫名其妙地差。后来加了输入长度校验才解决。这里的关键教训是:一定要前置校验 token 长度,不要相信上游数据。
第二个坑是多个模型向量混用。早期项目里,团队为了省成本,历史数据用旧模型向量化,新数据用新模型,结果检索效果雪崩。原因就是不同模型的向量空间不一致,强行放在一个索引里比较,只会得到一堆噪声。解决方式很简单:换模型就全量重建索引,这个过程也要写进发布流程。
第三个坑是 PCA 降维导致精度暴跌。有段时间为了省存储,我们把 1024 维向量做主成分分析压到 256 维,结果 Recall@5 掉了十几个点。后来才意识到:embedding 模型的每个维度承载的信息分布并不均匀,暴力 PCA 降维会破坏语义结构。如果要降维,应该优先选本身支持降维的模型(比如商业 API 通过参数控制维度),或者直接用 float16 压缩来省存储,效果稳定得多。
6.2 给后来者的建议
Embedding 和向量化这条链路,单看每一步都不难,难的是把选型、管线、存储、优化、监控组合成一个能长期稳定运行的整体。如果让我给一个最朴素的建议,那就是:先跑通最小闭环,再谈规模。先用几百条文档、一个开源模型、一个轻量向量库,把“清洗 → 分块 → 向量化 → 检索 → 生成”的完整链路跑起来,再逐步加数据量、加并发、加 Rerank。不要在一开始就追求大而全的架构,向量化领域可调的变量太多了,没有一个稳固的基线,你根本分不清效果波动到底来自哪一步。