AI文档检索折腾手记
很多人一上来就讲 AI 文档检索的全景图;这篇只记这次从 ES 关键词搜切到语义检索时卡住的点。
问题背景
原系统基于 Elasticsearch:用户输关键词,倒排索引匹配,再按 TF-IDF 或 BM25 排序。
这种方案的问题在几类场景下暴露得很明显:
同义词搜索不行。用户搜"性能调优",文档里写的是"性能优化",结果完全不匹配。我们加同义词词典,但维护成本高,覆盖率永远不够。
概念理解差。用户搜"如何解决慢查询",文档里虽然有相关内容,但用的是"查询优化"这个表述,直接就过滤掉了。
跨语言不灵。团队有中英文文档,用户用中文搜,英文内容直接消失,反之亦然。
长查询处理弱。用户输入一段完整的问题描述,系统只能从中提取关键词,语义关系完全丢失。
最要命的是,用户对搜索结果的理解和系统的计算逻辑完全是两个世界。用户想的是"我要解决什么问题",系统算的是"这个词在文档里出现了几次"。
语义检索原理
语义检索的核心思想是把文本变成向量,通过计算向量之间的相似度来匹配内容。简单说就是:把"我要找什么"和"文档有什么"都映射到同一个向量空间,然后用距离度量相似性。
检索流程大概是这样:
从工程角度看,实现语义检索需要解决几个关键问题:
文本如何表示。用什么模型把文本变成向量,向量维度多少,用什么距离度量。
如何高效检索。向量空间里的相似度计算需要遍历所有向量,怎么加速这个过程。
混合检索。语义搜索和关键词搜索怎么结合,各自权重的平衡。
重排序。初步检索结果如何进一步精细化排序。
这些概念听起来抽象,但落到工程上都有现成的工具链,主要工作是集成和调优。
技术方案选型
整个方案围绕三个核心组件构建:文本编码、向量索引、混合检索。
文本编码模型
编码模型是整个系统的基础,直接决定检索质量。调研了几类方案:
基于多语言 BERT 的模型,比如 sentence-transformers 系列。优势是语义理解强,支持多语言,社区活跃。劣势是推理慢,对硬件要求高。
基于轻量级 Transformer 的模型,比如 distilbert。速度快一些,但语义理解能力有所损失。
基于开源中文模型,比如 BGE 系列。针对中文优化,速度快,效果好。
最终选了 BAAI/bge-large-zh-v1.5。几个原因:中文效果好,推理速度可接受,支持多语言(虽然是中文主模型,但英文也有不错的表现),社区支持好,部署经验丰富。
模型推理用 Sentence Transformers 库,一行代码就能把文本变成 1024 维向量:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
query_embedding = model.encode("数据库索引优化方法")
doc_embedding = model.encode("MySQL索引设计与性能调优")
文档处理流程
文档处理是检索质量的关键。原来的直接全文索引问题很多,现在改进为:
文档预处理。提取纯文本,去除 HTML 标签、特殊字符、乱码。
文本切分。长文档需要切成合适的段落,太短信息不全,太长语义聚焦不足。
切分长度选择有讲究。实践发现 300-500 个汉字(约 200-300 tokens)是个不错的平衡点。太短比如 100 字以内的段落,语义信息不够,匹配质量差;太长比如超过 800 字,单个段落包含多个主题,检索时干扰大。
切分时还要考虑语义完整性,尽量在句子边界、章节标题处切分,不要把一个完整的思想打散。
def split_text(text, max_length=500):
sentences = text.split('。')
chunks = []
current_chunk = ""
for sentence in sentences:
if len(current_chunk + sentence) < max_length:
current_chunk += sentence + "。"
else:
chunks.append(current_chunk.strip())
current_chunk = sentence + "。"
if current_chunk:
chunks.append(current_chunk.strip())
return chunks
向量索引构建
有了向量就需要索引。直接暴力计算余弦相似度在文档量小的时候还行,几千篇文档还能接受,但到几万篇就明显感觉慢了。
调研了几类索引方案:
暴力索引。最简单直接,但查询复杂度 O(n),文档量大时不可行。
树索引。比如 KD-Tree,但高维向量下性能退化严重。
聚类索引。先把向量聚类,查询时只在最近的几个簇里找,比如 FAISS 的 IVF 系列算法。
图索引。比如 HNSW,构建层级图结构,查询效率高,但索引构建慢,内存占用大。
最终选了 FAISS 的 IVF-Flat,查询效率和索引构建都比较均衡。IVF-Flat 的思路是先用聚类算法把向量分成若干个簇(比如 1000 个),查询时只到最近的几个簇里找,减少计算量。
import faiss
import numpy as np
# 假设已经有文档向量列表,shape 是 (N, 1024)
embeddings = np.array([doc.embedding for doc in documents]).astype('float32')
# 构建索引:1024 维,1000 个簇
index = faiss.IndexIVFFlat(faiss.IndexFlatL2(1024), 1024, 1000)
index.train(embeddings)
index.add(embeddings)
# 查询
query_vector = np.array([query_embedding]).astype('float32')
distances, indices = index.search(query_vector, k=10)
索引参数调优有几个经验值:
簇数量。一般是文档数量的 sqrt 值或者文档数量除以某个系数。比如 10000 篇文档,簇数量可以设为 100-500。
查询时探查的簇数量(nprobe)。太少可能漏掉相关文档,太多查询变慢。实践发现 nprobe 设为 10-50 效果不错。
检索架构设计
实际部署时采用了分层架构:
存储层。用 PostgreSQL 存储原始文档和元数据,向量索引用 FAISS 单独存。
服务层。提供检索 API,处理查询逻辑。
缓存层。用 Redis 缓存热点查询结果,减少重复计算。
监控系统。记录查询延迟、命中率、用户反馈,用于持续优化。
混合检索策略
纯语义检索在某些场景下不如传统关键词搜索。比如用户搜具体的技术术语、错误代码、版本号,这些内容词本身就在传达信息,不需要语义理解。
所以采用了混合检索策略:
关键词检索。用 BM25 算法在文档内容中搜索关键词匹配的文档。
语义检索。用向量相似度找到语义相关的文档。
结果融合。对两路检索的结果进行加权融合,综合排序。
融合权重的调优是关键。初始时给了语义检索 0.6 的权重,关键词检索 0.4,通过 A/B 测试和用户反馈不断调整,最终稳定在 0.7/0.3 的比例。
还有一个细节是召回策略。关键词检索可能找到完全匹配的具体术语,语义检索可能找到概念相关但用词不同的内容,两者互补效果更好。
def hybrid_search(query, keyword_weight=0.3, semantic_weight=0.7):
# 关键词检索
keyword_results = bm25_search(query)
# 语义检索
semantic_results = vector_search(query)
# 融合打分
final_scores = {}
for doc, score in keyword_results.items():
final_scores[doc] = score * keyword_weight
for doc, score in semantic_results.items():
if doc in final_scores:
final_scores[doc] += score * semantic_weight
else:
final_scores[doc] = score * semantic_weight
# 按综合得分排序
sorted_results = sorted(final_scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results
实施过程与踩坑
方案确定后就开始实施,但落地过程中遇到了不少坑。
文档切分的坑
最开始按固定长度切分,把文档机械地切成每段 500 字。问题是经常把一个完整的技术方案切到两段里,每一段都只有一半信息,检索时要么匹配不上,要么匹配上了但不完整。
改进策略:先按章节、标题等自然结构切分,再把超长的章节按句子边界继续切,保证每个切分都是语义完整的单元。这样处理后的检索质量明显提升。
向量维度的坑
试过几种维度的编码模型,从 256 维到 768 维。理论上维度越高表示能力越强,但实际发现 768 维的模型在我们的数据集上效果没有明显提升,反而索引构建和查询都变慢了。
最终选了 1024 维的 BGE-large 模型,但这个权衡是试出来的。对于中小规模的文档库(几万篇),512-768 维可能已经够用,没必要一味追求高维。
跨语言的坑
虽然 BGE-large-zh-v1.5 标称支持多语言,但实际测试发现中文和英文的语义空间不是完全对齐的。中文查询"索引优化"和英文文档"index tuning"的相似度不如预期。
解决方案:针对跨语言场景,专门训练或微调一个跨语言模型,或者在索引时把中文和英文文档分别建立索引,查询时根据语言选择相应的索引。
我们采用了折中方案:在文档元数据中记录语言类型,检索时根据查询语言优先检索同语言文档,再补充检索其他语言文档。
性能优化的坑
上线初期查询延迟在 500ms 左右,用户反馈有点慢。分析发现主要瓶颈在:
向量检索。FAISS 的 IVF-Flat 在簇数量和 nprobe 参数上还有优化空间。
编码推理。每次查询都要把查询文本编码成向量,模型推理本身有延迟。
缓存效果差。用户查询的重复率不高,缓存命中率只有 15% 左右。
优化措施:
调整索引参数。把簇数量从 1000 增加到 2000,nprobe 从 50 减少到 20,查询延迟降到 200ms 左右。
模型优化。用 ONNX Runtime 加速推理,编码时间从 80ms 降到 30ms。
缓存策略。查询结果和中间向量编码都缓存,热点查询可以跳过编码步骤。
最终查询延迟稳定在 150-200ms,用户反馈基本可以接受。
质量评估的坑
检索质量怎么评估是个问题。最初想用标准指标,比如准确率、召回率、NDCG,但发现没有标注数据集,人工标注成本又太高。
现实的做法是:
用户反馈。在搜索结果页加"是否有帮助"的反馈按钮,收集用户对检索质量的直接评价。
A/B 测试。同时上线新旧两个检索系统,随机分配用户,比较点击率、停留时间等指标。
抽样检查。人工抽样检查检索结果的质量,看相关性和覆盖度。
基于这些方式逐步调优,虽然不是严格意义上的科学评估,但对工程实践来说已经够用。
结果与效果
系统上线后,检索效果有几个明显改善:
相关度提升。用户反馈"找不到想要的"的抱怨减少了 70%左右,首页结果的相关性明显提高。
同义词和概念理解。用户搜"性能调优"能返回"性能优化"的内容,长查询的处理也更精准。
跨语言覆盖。中文查询能返回英文文档,双语内容覆盖更好。
用户满意度。从反馈数据看,用户对检索结果的满意度从之前的 3.2 分(5 分制)提升到 4.1 分。
性能可接受。查询延迟控制在 200ms 以内,对于内部知识库的使用场景来说够用。
维护成本。虽然比原来的关键词搜索复杂一些,但整体架构相对稳定,维护成本可控。
仍然存在的边界
这次升级解决了大部分问题,但有些边界还是客观存在的:
计算资源。语义检索对计算资源的要求比传统搜索高,需要更好的硬件支撑。
查询延迟。虽然优化到了 200ms 左右,但如果是面向公众的搜索服务,这个延迟还是偏高。
极端语义。对于非常专业或者非常口语化的表达,语义理解还是有局限,需要针对领域做专门优化。
实时性。新文档入库需要重新编码和索引,实时性不如传统搜索。
这些边界要么是技术本身的限制,要么是资源成本的权衡,暂时接受但持续关注。
结语
从传统搜索到语义检索,本质上是让系统从"匹配字面"进化到"理解意图"。这条路技术复杂度不高,但工程上有很多细节需要调优。
这次升级让我重新想了一遍:搜索要解决的是"帮用户找到那个答案",不是"堆一页结果"。向量检索只是换了一种匹配方式,目标没变。
下一步想试重排序和查询改写,都是后话。当前系统已经盖住 80% 的日常查询,剩下 20% 慢慢迭代就行——合适比先进重要。
版权声明: 本文首发于 指尖魔法屋-AI文档检索折腾手记(https://blog.thinkmoon.cn/post/370-ai-document-retrieval-search-accurate-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。