AI文档检索折腾手记

很多人一上来就讲 AI 文档检索的全景图;这篇只记这次从 ES 关键词搜切到语义检索时卡住的点。

问题背景

原系统基于 Elasticsearch:用户输关键词,倒排索引匹配,再按 TF-IDF 或 BM25 排序。

这种方案的问题在几类场景下暴露得很明显:

同义词搜索不行。用户搜"性能调优",文档里写的是"性能优化",结果完全不匹配。我们加同义词词典,但维护成本高,覆盖率永远不够。

概念理解差。用户搜"如何解决慢查询",文档里虽然有相关内容,但用的是"查询优化"这个表述,直接就过滤掉了。

跨语言不灵。团队有中英文文档,用户用中文搜,英文内容直接消失,反之亦然。

长查询处理弱。用户输入一段完整的问题描述,系统只能从中提取关键词,语义关系完全丢失。

最要命的是,用户对搜索结果的理解和系统的计算逻辑完全是两个世界。用户想的是"我要解决什么问题",系统算的是"这个词在文档里出现了几次"。

语义检索原理

语义检索的核心思想是把文本变成向量,通过计算向量之间的相似度来匹配内容。简单说就是:把"我要找什么"和"文档有什么"都映射到同一个向量空间,然后用距离度量相似性。

检索流程大概是这样:

flowchart LR A[用户查询] --> B[编码成向量] C[文档库] --> D[预处理切分] D --> E[编码成向量] E --> F[构建向量索引] B --> G[向量相似度检索] F --> G G --> H[返回结果]

从工程角度看,实现语义检索需要解决几个关键问题:

文本如何表示。用什么模型把文本变成向量,向量维度多少,用什么距离度量。

如何高效检索。向量空间里的相似度计算需要遍历所有向量,怎么加速这个过程。

混合检索。语义搜索和关键词搜索怎么结合,各自权重的平衡。

重排序。初步检索结果如何进一步精细化排序。

这些概念听起来抽象,但落到工程上都有现成的工具链,主要工作是集成和调优。

技术方案选型

整个方案围绕三个核心组件构建:文本编码、向量索引、混合检索。

文本编码模型

编码模型是整个系统的基础,直接决定检索质量。调研了几类方案:

基于多语言 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 缓存热点查询结果,减少重复计算。

监控系统。记录查询延迟、命中率、用户反馈,用于持续优化。

flowchart LR A[用户查询] --> B[查询接口层] B --> C[缓存层] C -->|未命中| D[向量检索] D --> E[FAISS索引] E --> F[结果排序] F --> G[返回结果] C -->|命中| G

混合检索策略

纯语义检索在某些场景下不如传统关键词搜索。比如用户搜具体的技术术语、错误代码、版本号,这些内容词本身就在传达信息,不需要语义理解。

所以采用了混合检索策略:

关键词检索。用 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!