1. 项目概述为什么召回系统需要“四库全书”做搜索或者推荐的朋友对“召回”这个概念肯定不陌生。简单说就是从海量数据里快速捞出一小撮最可能相关的候选集交给后面的精排模型去细品。但很多人一上来就琢磨算法、调模型结果发现第一步——数据准备——就卡住了。数据没备好再牛的算法也是巧妇难为无米之炊。今天要聊的就是召回系统启动前那件最基础也最要命的事搭好召回所需的四个数据库。这“四库全书”分别是关系型数据库如MySQL、全文检索引擎如Elasticsearch、向量数据库如Qdrant和缓存数据库如Redis。你可能觉得一个系统用这么多数据库是不是太复杂了其实不然这正是现代召回架构“术业有专攻”的体现。MySQL管结构化元数据Elasticsearch负责关键词和复杂条件检索Qdrant专精向量相似度搜索Redis则扛起高频访问数据缓存的大旗。它们各司其职共同构成了一个高效、稳定且灵活的召回数据底座。我见过不少项目初期为了省事把所有数据都往MySQL里一塞等到要做语义搜索或者面临高并发压力时就开始各种拆东墙补西墙性能瓶颈和开发复杂度陡增。所以在项目启动之初就根据数据特性和访问模式规划好这四类数据库的选型和部署能为后续的召回、乃至整个RAG检索增强生成或推荐系统打下最坚实的地基。接下来我就结合实操把这四个库的搭建要点、避坑经验和融合使用的心得给你掰开揉碎了讲清楚。2. 核心需求解析召回场景下的数据访问模式在动手建库之前我们必须先想明白召回阶段我们的系统到底需要以什么样的方式、多快的速度、获取什么样的数据这决定了我们为何需要这四种不同的数据库。2.1 多模态数据与混合检索需求现代召回早已不是简单的关键词匹配了。以RAG系统为例用户的一个问题可能需要同时从多个维度去“捞”答案精确匹配与属性过滤比如“找出2023年发布的、关于神经网络的所有论文”。这里“2023年”、“论文”是精确字段“神经网络”是关键词。这类查询关系型数据库和倒排索引引擎Elasticsearch擅长。语义相似度搜索比如用户问“如何让电脑学得更快”我们可能需要找到内容为“提升机器学习模型训练效率的方法”的文档。这就需要将问题和文档都转化为向量计算余弦相似度。这是向量数据库的专场。多路召回与融合为了覆盖更全我们通常会并行发起多路召回一路走Elasticsearch进行关键词BM25检索一路走Qdrant进行向量语义检索。然后将两路结果融合、去重、重排序。这就需要各数据库能独立高效地工作。高频元数据与实时性要求物品/文档的热度分数、实时点击统计、用户画像标签用于个性化召回等需要极快的读写速度且可能频繁变化。用MySQL直接查太慢这就需要缓存数据库。2.2 性能与扩展性考量召回处于链路的最前端直接面对海量候选集性能压力巨大。吞吐量需要支持高并发查询特别是向量检索计算密集。延迟要求毫秒级响应任何一环慢了整个系统响应就慢。扩展性数据量增长后能否方便地水平扩展分片。Elasticsearch和Qdrant天生分布式友好MySQL分库分表则复杂得多。理解了这些需求我们就能明白用一个数据库通吃所有场景要么牺牲性能要么增加巨大的开发复杂度。采用多数据库混合架构是当前平衡功能、性能和成本的最优解。3. 数据库选型与部署实战理论清楚了我们进入实战环节。我会以最典型的组合MySQL Elasticsearch Qdrant Redis为例带你走一遍从安装部署到基础配置的全过程并分享我的选型理由和避坑指南。3.1 关系型数据库MySQL的基石作用与配置MySQL在这里扮演“数据源”和“元数据管家”的角色。所有结构化的、需要强一致性的数据都放在这里比如文档的唯一ID、标题、作者、发布时间、类别、状态等。选型理由成熟稳定生态完善事务支持好作为数据源头可靠。虽然召回不直接用它做复杂查询但它是其他数据库数据的“根”。部署与核心配置 我强烈建议使用Docker部署干净、隔离、易迁移。# 拉取MySQL 8.0镜像 docker pull mysql:8.0 # 运行容器 docker run -d \ --name mysql-recall \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDyour_strong_password \ -e MYSQL_DATABASErecall_base \ -v /your/local/path/mysql_data:/var/lib/mysql \ mysql:8.0 \ --character-set-serverutf8mb4 \ --collation-serverutf8mb4_unicode_ci \ --default-authentication-pluginmysql_native_password注意utf8mb4字符集是必须的以支持完整的Unicode如Emoji。挂载数据卷-v参数是为了持久化数据避免容器删除后数据丢失。建表示例与心得 为召回准备的主表设计要考虑到后续同步到ES和Qdrant的便利性。CREATE TABLE document_base ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键也是全局唯一标识, title VARCHAR(500) NOT NULL DEFAULT COMMENT 标题, content_text LONGTEXT COMMENT 原始文本内容用于生成向量和索引, author VARCHAR(100) DEFAULT NULL, publish_time DATETIME DEFAULT CURRENT_TIMESTAMP, category_id INT DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-有效0-无效, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category_status (category_id,status), KEY idx_publish_time (publish_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT文档基础表;实操心得务必有一个content_text或类似的字段存储用于向量化和全文索引的纯净文本。这个字段的质量直接决定召回效果。id字段最好是无意义的自增主键并作为其他系统ES、Qdrant中的唯一关联键。索引不要乱加。除了主键通常按category_id和status这种高频过滤条件以及publish_time这种排序字段建联合索引就够了。召回阶段很少在MySQL做复杂条件查询。3.2 全文检索引擎Elasticsearch的索引策略当用户进行明确的关键词搜索、或需要根据多个字段进行复杂筛选时ElasticsearchES是我们的王牌。它基于倒排索引对文本的分词、模糊匹配、权重查询非常高效。选型理由开源领域全文检索事实标准分布式能力强大查询语法丰富支持复杂的聚合分析。对于非语义的、基于字面匹配的召回场景不可或缺。部署与调优 同样使用Docker但ES对内存和虚拟内存有要求。# 调整系统参数Linux sudo sysctl -w vm.max_map_count262144 # 拉取ES镜像以7.17版本为例较稳定 docker pull elasticsearch:7.17.21 # 运行容器 docker run -d \ --name es-recall \ -p 9200:9200 -p 9300:9300 \ -e discovery.typesingle-node \ -e ES_JAVA_OPTS-Xms2g -Xmx2g \ -v /your/local/path/es_data:/usr/share/elasticsearch/data \ elasticsearch:7.17.21注意vm.max_map_count设置是ES运行必须的否则会启动失败。生产环境通常需要集群部署这里用single-node模式简化演示。-Xms和-Xmx设置JVM堆内存根据机器资源调整一般设为物理内存的一半左右。索引Mapping设计 这是ES的核心决定了数据如何被索引和查询。我们的目标是为召回优化。PUT /document_index { settings: { number_of_shards: 3, number_of_replicas: 1, analysis: { analyzer: { ik_smart_analyzer: { type: custom, tokenizer: ik_smart } } } }, mappings: { properties: { doc_id: {type: keyword}, title: { type: text, analyzer: ik_smart_analyzer, copy_to: combined_text }, content: { type: text, analyzer: ik_smart_analyzer, copy_to: combined_text }, author: {type: keyword}, category_id: {type: integer}, publish_time: {type: date}, combined_text: { type: text, analyzer: ik_smart_analyzer } } } }核心要点解析分词器我们使用了IK分词器需要提前安装插件ik_smart适合召回粒度较粗保证召回率。ik_max_word更细适合高精度搜索但可能增加噪声。copy_to这是一个非常实用的技巧。将title和content复制到combined_text字段。这样当用户查询时我们既可以针对title和content单独设置权重查询也可以直接搜索combined_text字段进行全局匹配非常灵活。字段类型keyword用于精确匹配和聚合text用于全文检索date和integer用于范围过滤。区分清楚能提升性能和准确性。分片与副本number_of_shards在创建索引后不可更改要根据数据量预估每个分片建议30-50GB。number_of_replicas可以提高读取吞吐量和可用性可以后期调整。3.3 向量数据库Qdrant的集合配置对于语义召回我们需要将文本转换为高维向量Embedding然后存储到向量数据库中进行最近邻搜索ANN。Qdrant是一款高性能、开源且易用的向量数据库。选型理由vs Milvus, Weaviate等API设计简洁RESTful/gRPC用Rust编写性能出色支持多种距离计算方式余弦、点积、欧几里得并且自带Payload附加数据概念可以很方便地存储和过滤元数据与我们的多路召回架构契合度高。部署与集合创建 Qdrant的Docker部署极其简单。# 拉取Qdrant镜像 docker pull qdrant/qdrant # 运行容器 docker run -d \ --name qdrant-recall \ -p 6333:6333 -p 6334:6334 \ -v /your/local/path/qdrant_storage:/qdrant/storage \ qdrant/qdrant访问http://localhost:6333/dashboard可以看到内置的管理界面。创建集合Collection类似ES的索引或MySQL的表curl -X PUT http://localhost:6333/collections/document_vectors \ -H Content-Type: application/json \ -d { vectors: { size: 768, distance: Cosine }, optimizers_config: { default_segment_number: 2 } }参数详解与避坑size: 向量维度。必须与你使用的Embedding模型如text-embedding-3-small输出1536维bge-large-zh输出1024维输出维度严格一致。这是最常见的错误来源。distance: 距离度量方式。Cosine余弦相似度最常用于文本相似度。Dot点积在某些模型下也常用但需注意向量是否已归一化。Euclid欧氏距离用于更一般的空间距离。optimizers_config: 优化器配置。default_segment_number影响索引构建和搜索性能。对于快速迭代的开发环境或中小规模数据可以设小一点如2以加快索引速度。生产环境大数据量下需要根据官方文档和硬件资源进行调优。Payload是灵魂Qdrant不直接存储原始文本而是存储向量和与之关联的Payload。Payload是JSON格式应包含至少doc_id关联MySQL主键、title等用于结果展示和过滤的信息。后续插入向量点时必须带上。3.4 缓存数据库Redis的选型与数据结构设计Redis用于缓存热点数据加速访问。在召回系统中它可以缓存用户最近行为、热门物品ID列表、某些复杂查询的中间结果、甚至是一些小的模型参数。选型理由内存存储速度极快数据结构丰富String, Hash, List, Set, Sorted Set能灵活应对多种缓存场景。部署docker pull redis:7-alpine docker run -d --name redis-recall -p 6379:6379 redis:7-alpine数据结构设计示例缓存热门文档ID列表Sorted Set# 键名hot_docs:{category_id} 分数为热度值如点击量成员为doc_id ZADD hot_docs:1 1000 12345 800 67890 # 获取Top10 ZREVRANGE hot_docs:1 0 9 WITHSCORES这样在做召回时可以快速获取某个类别下的热门物品融入召回策略。缓存用户最近点击List# 键名user_recent_clicks:{user_id} LPUSH user_recent_clicks:1001 12345 LTRIM user_recent_clicks:1001 0 49 # 只保留最近50条用于实现“看了又看”或排除已读内容的个性化召回。缓存向量模型结果String# 键名emb_cache:{text_md5} 值为向量序列化后的字符串如用json.dumps SET emb_cache:$(echo -n 机器学习简介 | md5sum | cut -d -f1) [...vector array...] EX 86400对于频繁出现的相同查询文本可以缓存其Embedding结果避免重复调用模型节省成本和时间。设置过期时间EX很重要。实操心得键名设计使用冒号分隔的命名空间如业务:对象:ID清晰且便于用KEYS或SCAN模式管理。内存监控Redis是内存数据库务必监控内存使用量并配置合理的淘汰策略maxmemory-policy如allkeys-lru。持久化考虑缓存数据允许丢失但根据业务情况可配置RDB或AOF持久化防止重启后缓存雪崩对数据库造成冲击。4. 数据同步与管道构建四个库建好了数据如何保持同步核心原则是MySQL作为唯一可信数据源其他数据库的数据通过监听MySQL变更来同步。这能最大程度保证数据一致性。4.1 基于Binlog的增量同步方案对于Elasticsearch和Qdrant我们通常需要近乎实时地同步。推荐使用Canal或Debezium这类工具监听MySQL的Binlog。以Canal为例的架构流程启用MySQL Binlog确保MySQL配置文件中log-bin已开启。部署Canal Server解析Binlog将其转换为更容易消费的数据变更事件INSERT/UPDATE/DELETE。编写Canal ClientAdapter消费Canal Server发出的事件根据业务逻辑更新ES和Qdrant中的数据。对于ES将变更转换为对/document_index/_update_by_query或直接indexAPI的调用。对于Qdrant如果是新增或更新需要先调用Embedding模型API将文本转为向量然后调用Qdrant的upsert接口插入点如果是删除则调用delete接口。同步逻辑中的难点与处理批量操作不要逐条同步应积累一定数量如100条或一段时间如1秒进行批量操作大幅提升效率。更新与删除ES更新相对直接。Qdrant的更新本质上是先删后增因为向量变了需要特别注意原子性。删除操作必须通过doc_id等唯一标识进行。失败重试与幂等性网络抖动或服务暂时不可用会导致同步失败。必须实现重试机制并且保证同步操作是幂等的即重复执行相同操作结果不变。例如使用doc_id作为ES文档ID和Qdrant Point IDupsert操作天生具有幂等性。4.2 全量初始化与定期校对在系统首次上线或数据源发生重大变更后需要进行全量同步。全量初始化从MySQL中分批读取全量数据如每次1万条并行地生成向量、构建ES索引、插入Qdrant集合。这个过程可能很耗时要做好进度监控和错误处理。定期校对由于增量同步可能存在极低概率的丢失如Canal服务长时间宕机需要定期如每天凌晨运行一个校对任务对比MySQL、ES、Qdrant中核心数据如总数、关键字段校验和的一致性并修复差异。4.3 Redis缓存的更新策略Redis缓存的数据通常有更强的业务逻辑更新策略多样写时更新Write-Through当MySQL数据变更如文章点击量增加时同步更新Redis中的缓存。这保证了强一致性但增加了写延迟。读时更新Cache-Aside这是更常见的模式。读缓存若不存在则读DB并回填缓存。写操作直接更新DB并**失效删除**对应的缓存。下次读时自然会回填新数据。这种方式简单有效但存在缓存击穿大量并发请求同一个不存在的key和短暂不一致的风险。定时刷新对于热门榜单这类数据可以每隔一段时间如5分钟从DB计算一次全量刷新到Redis。在我们的召回场景中Cache-Aside结合定时刷新是常用策略。例如用户点击行为直接写DB并异步发送消息失效相关缓存热门榜单则由定时任务计算并刷新。5. 召回查询的融合与编排当四个数据库各就各位数据也同步完毕后我们如何利用它们完成一次高效的召回请求这就是召回服务层的核心逻辑。5.1 多路召回查询示例假设我们有一个查询“推荐几本好看的科幻小说”。关键词召回Elasticsearch# 使用 combined_text 字段进行搜索提升召回率 es_query { query: { match: { combined_text: { query: 科幻 小说 推荐 好看, operator: or # 使用OR逻辑扩大召回范围 } } }, _source: [doc_id, title, score], size: 100 # 召回数量可设置多一些 }这路召回能抓住明确包含“科幻”、“小说”等关键词的文档。语义向量召回Qdrant# 首先将查询文本转换为向量 query_vector embedding_model.encode(推荐几本好看的科幻小说) # 然后在Qdrant中搜索 qdrant_search_params { vector: query_vector.tolist(), limit: 100, with_payload: True, # 必须获取payload中的doc_id等信息 with_vector: False, filter: { # 可以利用payload进行过滤例如只要“小说”类别的 must: [ {key: category, match: {value: 小说}} ] } }这路召回能抓住语义上相关但可能没有明确关键词的文档比如内容在讲“太空歌剧”、“硬核科幻”但没直接出现“科幻小说”字样的。热门度召回Redis从Redis的hot_docs:科幻假设有此类目Sorted Set中取出Top N的doc_id。5.2 结果融合与重排序现在我们得到了三份候选ID列表及其原始分数ES的_score Qdrant的相似度分数 Redis的热度分数。它们量纲不统一不能直接比较。分数归一化常用的方法有Min-Max归一化或Z-Score标准化。将每一路召回结果的分数映射到[0, 1]区间。# 以Min-Max为例 def min_max_normalize(scores): min_s, max_s min(scores), max(scores) range_s max_s - min_s if range_s 0: return [0.5] * len(scores) # 处理所有分数相同的情况 return [(s - min_s) / range_s for s in scores]加权融合根据业务经验给每一路召回分配一个权重。例如初期可以设语义召回权重0.5关键词召回权重0.3热门召回权重0.2。final_score w1 * norm_score_es w2 * norm_score_qdrant w3 * norm_score_hot去重与排序根据doc_id合并结果按融合后的final_score降序排列取Top K如50条作为最终召回结果送入后续的精排阶段。高级策略动态权重可以根据查询意图动态调整权重。例如检测到查询很短、很模糊可能提高热门召回权重查询很长、很具体则提高语义和关键词召回权重。多样性保障在融合时可以不仅考虑分数也考虑类目、来源的多样性避免结果过于同质化。例如从每一路召回中都保证选取一定数量的结果。6. 性能调优与监控告警系统跑起来之后持续的优化和监控才能保证其长期稳定运行。6.1 各数据库性能调优要点MySQL确保为同步查询条件如WHERE category_id? AND status1建立了有效的联合索引。监控慢查询日志定期优化表OPTIMIZE TABLE对于有大量更新删除的表有效。主从分离将同步读请求如Canal读取Binlog指向从库减轻主库压力。Elasticsearch索引层面根据数据冷热使用ILM索引生命周期管理策略将旧索引转移到更便宜的存储或关闭。查询层面避免深度分页fromsize改用search_after。合理使用filter上下文不计算评分可缓存替代query上下文。硬件层面ES吃内存确保给JVM Heap分配足够内存不超过物理内存50%且不超过32GB。使用SSD磁盘。Qdrant集合配置调整optimizers_config如max_optimization_threads和default_segment_number平衡索引构建速度和搜索性能。Payload索引对用于过滤的Payload字段如category创建索引可以极大加快带过滤的向量搜索速度。HNSW参数Qdrant默认使用HNSW图算法。调整ef_construct和m参数可以影响索引构建速度、内存占用和搜索精度/速度需要在你的数据集上进行测试。Redis避免使用KEYS *命令用SCAN代替。大Key如一个Hash里有百万字段和热Key某个Key访问量巨大是性能杀手需要从业务设计上规避或拆分。设置合理的最大内存和淘汰策略。6.2 监控指标与告警设置必须建立完善的监控体系核心指标包括资源层各数据库容器的CPU、内存、磁盘IO使用率。网络带宽。服务层可用性各数据库端口的连通性如3306, 9200, 6333, 6379。延迟平均查询响应时间P50, P95, P99。特别是ES和Qdrant的搜索延迟。吞吐量QPS每秒查询数。错误率查询失败、超时的比例。业务层数据同步延迟Canal消费Binlog的位点与当前时间的差距。各召回路线的结果数量如果某一路召回长期返回0结果可能意味着配置错误或数据问题。缓存命中率Redis缓存的命中率过低意味着缓存策略可能有问题。告警规则示例使用Prometheus Alertmanager当ES查询P99延迟连续5分钟 500ms时发出警告。当Redis内存使用率 85%时发出严重告警。当MySQL从库同步延迟 60秒时发出警告。7. 常见问题与故障排查实录在实际运维中你会遇到各种各样的问题。这里记录几个我踩过的坑和解决方法。问题一Qdrant向量搜索返回结果分数全是1.0或0.999且结果不相关。排查首先检查插入的向量和搜索时生成的向量是否来自同一个模型、相同的预处理流程。最常见的原因是向量维度不匹配。创建集合时指定的size是768但模型实际输出是1024。解决核对模型输出维度与集合配置维度。使用GET /collections/{collection_name}接口检查集合配置。重新创建正确维度的集合并同步数据。问题二Elasticsearch查询某些中文词无结果。排查检查索引的mapping确认目标字段使用的是否是IK分词器。使用_analyzeAPI测试分词效果。GET /document_index/_analyze { field: content, text: 你说的这个科幻小说 }解决如果发现分词不对可能是IK词典未加载新词。需要更新IK分词器的自定义词典。或者对于专有名词、品牌名等考虑使用keyword类型子字段进行精确匹配。问题三数据同步管道卡住ES/Qdrant数据不再更新。排查检查Canal Server和Client的日志看是否有错误如连接MySQL失败、网络超时。检查MySQL Binlog是否正常生成和清理。检查ES/Qdrant集群状态是否健康GET /_cluster/health。检查同步客户端是否因为消息处理失败如数据格式错误而进入死循环或崩溃。解决根据日志定位问题。如果是单条数据格式错误可以考虑加入死信队列跳过该条继续同步事后人工修复。如果是服务不可用先恢复服务然后检查Canal的位点可能需要从某个时间点重新同步。问题四召回服务响应时间偶尔飙升。排查查看监控是单一路线慢还是所有都慢如果是ES/Qdrant慢检查当时是否有大的批量写入如全量同步在进行消耗了大量资源。检查服务器资源CPU、内存、磁盘IO是否在慢查询时段出现瓶颈。检查是否有慢查询日志分析查询语句是否使用了低效的语法如通配符查询*。解决将离线批量任务如全量同步、索引重建安排在业务低峰期。优化查询语句使用filter避免脚本查询。对ES/Qdrant进行扩容增加节点或提升配置。搭建召回系统的四个数据库就像为一座大厦打下四根不同功能的桩基。这个过程繁琐但至关重要。我的体会是前期多花时间在数据模型设计、同步方案选型和性能基准测试上后期就能省下大量救火和重构的时间。不要试图用一个工具解决所有问题让专业的工具做专业的事通过清晰的架构将它们组合起来才能构建出既强大又灵活的召回系统。最后监控和告警一定要跟上系统上线只是开始持续的观察和优化才是常态。