上周排查一个线上问题,用户反馈“智能客服突然开始胡言乱语”。查日志发现,当用户问“退货政策最新版”时,系统返回的竟然是“2021年促销活动条款”。问题出在向量搜索环节——相似度最高的文档居然是三年前的旧政策。这个坑让我意识到,很多团队把向量数据库当黑盒用,结果就是线上时不时给你来个“惊喜”。

Embedding不是魔法

Embedding说白了就是把文本变成一串数字。这串数字得能代表语义,否则后续搜索全是白搭。

# 常见误区:直接拿BERT最后一层当embedding
# 别这样写!效果通常很差
from transformers import AutoModel, AutoTokenizer
model = AutoModel.from_pretrained("bert-base-chinese")
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")

inputs = tokenizer("退货政策", return_tensors="pt")
outputs = model(**inputs)
last_hidden = outputs.last_hidden_state  # 形状: [1, seq_len, 768]
cls_vector = last_hidden[0][0]  # 取[CLS] token

# 问题:BERT的[CLS]未经专门训练做语义表示
# 除非你fine-tune过,否则效果不稳定

更靠谱的做法是用专门优化过的模型:

# 推荐:用Sentence-BERT或专门优化的模型
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
vector = model.encode("退货政策最新版")  # 得到384维向量
# 关键:这些模型在训练时就让相似句子的向量靠近

实际项目中,我习惯加个归一化:

import numpy as np
vector = vector / np.linalg.norm(vector)  # L2归一化
# 这样后续算余弦相似度直接点积就行,省计算量

相似度搜索的坑

很多人以为向量数据库就是“存向量、搜最近邻”,其实里面门道不少。

# 踩过的一个坑:默认参数不一定适合你
import faiss

dimension = 384
index = faiss.IndexFlatIP(dimension)  # 内积索引,假设向量已归一化

# 问题来了:当你有100万条数据时,线性扫描太慢
# 线上服务延迟直接飙到500ms+

# 解决方案:用IVF索引
quantizer = faiss.IndexFlatIP(dimension)
index = faiss.IndexIVFFlat(quantizer, dimension, 100)  # 100个聚类中心
index.train(training_vectors)  # 必须训练!
index.add(data_vectors)

# 搜索时控制搜索范围
index.nprobe = 10  # 搜索10个最近聚类
# nprobe越大越准但越慢,需要实测调优

还有个容易忽略的点:距离度量选错全盘皆输。我们曾经用欧氏距离搜文本,结果召回率惨不忍睹。文本相似度搜索,余弦相似度(或归一化后的内积)才是正解。

RAG的核心不是检索是增强

开头那个客服问题,根源在于RAG(检索增强生成)系统只做了“检索”,没做好“增强”。

# 简陋版RAG(很多项目的现状)
def naive_rag(query):
    query_vector = embed(query)
    results = vector_db.search(query_vector, k=3)  # 找最相似的3条
    context = "\n".join([doc.text for doc in results])
    prompt = f"参考:{context}\n问题:{query}"
    return llm.generate(prompt)

# 问题1:如果top3都是旧政策,LLM只能基于错误信息回答
# 问题2:没有去重,相似文档重复出现
# 问题3:没考虑时效性

改进后的版本:

def better_rag(query, user_id=None):
    # 1. 多路召回
    keyword_results = keyword_search(query)  # 传统关键词检索
    vector_results = vector_search(query)    # 向量检索
    
    # 2. 重排序
    all_results = rerank(query, keyword_results + vector_results)
    
    # 3. 去重和过滤
    unique_results = remove_duplicates(all_results)
    filtered = filter_by_date(unique_results, cutoff="2023-01-01")
    
    # 4. 个性化(如果有用户历史)
    if user_id:
        user_pref = get_user_preference(user_id)
        filtered = personalize(filtered, user_pref)
    
    # 5. 构造带元数据的prompt
    context = format_with_metadata(filtered[:5])  # 带来源、时间戳
    prompt = build_prompt(context, query, system_role="客服专家")
    
    # 6. 让LLM判断是否足够回答
    return llm.generate_with_fallback(prompt)

这里的关键是:向量搜索只是召回手段之一。最终效果取决于整个pipeline的设计——怎么召回、怎么排序、怎么构造prompt。

工程实践建议

  1. Embedding模型选型:别盲目追新,小模型+精调往往比大模型+zero-shot更实用。我们线上用384维的模型,效果不比768维差,但速度快一倍,内存省一半。

  2. 混合搜索是王道:纯向量搜索在术语匹配上容易翻车。比如用户搜“Python”,向量可能返回“蟒蛇”,但关键词搜索不会错。两者结合再加个重排序模型,效果最稳。

  3. 元数据过滤必须做:给每个向量配上时间戳、来源、版本号。搜索时先按业务规则过滤,再算相似度。像政策、价格这种时效性强的,不加时间过滤就是埋雷。

  4. 监控召回质量:别只监控延迟和QPS。我们每天抽样100个查询,人工评估召回的相关性。发现bad case就分析是embedding问题、索引问题还是数据质量问题。

  5. 数据更新策略:向量索引重建成本高。我们采用增量更新:每天新增数据入临时索引,每周合并到主索引。重大业务变更(如政策大改)才全量重建。

  6. 硬件不是越贵越好:CPU做向量搜索可能比GPU更经济,尤其是QPS不高但数据量大的场景。实测过,百万级数据用AVX2优化的CPU索引,单次搜索<10ms。

向量数据库现在炒得火热,但记住它本质是工具。真正影响业务效果的,是你对业务场景的理解——知道什么时候该用余弦相似度什么时候该用欧氏距离,知道怎么平衡召回率和响应时间,知道哪些数据该进向量库哪些该放传统数据库。把这些想明白了,技术选型反而简单。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐