RAG查询改写后数字、缩写和型号消失?Token清洗与双路检索完整排查
文章摘要
技术文档、设备手册、API错误、商品目录和企业业务系统中,真正决定召回结果的往往不是自然语言,而是HTTP 429、Spring AI 2.0、ZX-4100B、SKU-00981、V2.3.1、EUDR、PUC等高信息密度Token。很多RAG系统为了“清洗输入”和“提升语义”,会删除标点、停用词、连字符、大小写或数字,最终导致BM25失去精确匹配,Dense Embedding也丢失关键区分度。
常见故障包括:HTTP 429只剩“限流问题”;Spring AI 2.0被改成Spring AI;型号中的连字符被拆开;C++被清洗成C;A/B测试被改写为“测试”;日期和版本号被错误归一化。由于改写后的句子仍然流畅,这类问题常被误判为向量数据库召回差或Embedding模型不适合中文。
本文将查询处理拆成Raw、Normalized、Sparse和Dense四种视图,建立受保护Token词法分析器、业务缩写词典、版本与错误码解析器、原始Query稀疏检索、受控Dense改写、Token保真校验和回归数据集,并给出Spring Boot实现、指标和排查步骤。
一、一个最典型的错误
用户输入:
Spring AI 2.0升级后调用MCP Streamable HTTP返回429,ttlMs配置是否有问题?错误清洗:
spring ai 升级 调用 mcp streamable http 返回 限流 ttlms 配置 问题进一步改写:
Spring AI升级后MCP连接被限流,如何调整缓存时间?丢失信息:
2.0;429;ttlMs的大小写;- “是否有问题”这一诊断语义;
Streamable HTTP作为协议名称的整体性。
后果:
- BM25无法精确命中包含
429和ttlMs的排障文档; - Dense检索会泛化到所有“限流”和“缓存”文章;
- Reranker难以恢复已经被删除的Token;
- 最终回答可能讨论错误的限流策略。
二、为什么技术Token特别脆弱
1. 正则把非中文和字母数字当噪声
错误代码:
query.replaceAll("[^\\p{IsHan}a-zA-Z\\s]"," ");这会删除:
- 数字;
-小数点;
-连字符;
-斜杠;
-加号;
-井号。
2. 停用词表误删缩写
IT、US、OR、IN在英文中可能是停用词,但也可能是:
- 信息技术;
-国家代码;
-逻辑运算;
-SQL关键字。
3. 分词器拆散型号
ZX-4100B → ZX / 4100 / B拆分有时有利于召回,但如果没有保留完整Token,精确匹配能力会下降。
4. 大小写归一化破坏业务含义
mcp MCP Mcp自然语言可能等价,但大小写敏感代码、字段名和型号可能不同。
5. LLM“纠错”
模型可能把低频Token改为常见Token:
ttlMs → TTL6. 翻译改写
A/B Test → 对照实验自然语言等价,但技术文档可能只出现A/B Test。
三、不要只有一个Query字符串
推荐建立四种视图。
publicrecordQueryViews(Stringraw,Stringnormalized,StringsparseQuery,StringdenseQuery,Set<ProtectedLexeme>protectedLexemes,QueryNormalizationTracetrace){}Raw
用户原始输入,不允许覆盖。
Normalized
只做可逆或确定性规范化:
- Unicode;
-空白;
-全角半角;
-不可见字符;
-常见引号。
Sparse Query
面向BM25或倒排索引,最大程度保留精确Token。
Dense Query
面向Embedding,可进行受控改写,但必须保留关键Token和语义约束。
四、第一原则:Normalization必须可解释
错误:
Stringclean=raw.toLowerCase().replaceAll("[^a-z0-9\\u4e00-\\u9fa5 ]"," ").replaceAll("\\s+"," ").trim();问题:
- 不知道删除了什么;
- 无法恢复;
- 大小写全部丢失;
- 符号语义消失;
- 排障无法复现。
正确做法:
publicrecordNormalizationChange(NormalizationRulerule,Stringbefore,Stringafter,intstartOffset,intendOffset){}每个规则有版本:
publicrecordQueryNormalizationTrace(StringnormalizerVersion,List<NormalizationChange>changes,Set<String>removedFragments,Set<String>changedFragments){}五、哪些字符不能随便删除
技术Query常见符号:
| 符号 | 示例 | 可能含义 |
|---|---|---|
. | 2.0.1 | 版本 |
- | ZX-4100B | 型号或编号 |
_ | tenant_id | 字段名 |
/ | A/B、HTTP/2 | 协议或比较 |
+ | C++ | 语言名 |
# | C# | 语言名 |
: | error:429 | 键值或错误 |
@ | @McpTool | 注解 |
$ | ${tenantId} | 模板变量 |
% | 95% | 比例 |
处理策略不应是“保留所有符号”或“删除所有符号”,而是先识别Token类型。
六、受保护Token类型
publicenumLexemeType{VERSION,ERROR_CODE,HTTP_STATUS,MODEL_ID,SKU,DEVICE_MODEL,CONTRACT_ID,CLASS_NAME,METHOD_NAME,CONFIG_KEY,ANNOTATION,ACRONYM,DATE,PERCENTAGE,MONEY,FILE_PATH,URL_FRAGMENT,UNKNOWN_TECH_TOKEN}publicrecordProtectedLexeme(Stringraw,Stringcanonical,LexemeTypetype,intstart,intend,ProtectionModemode,doubleconfidence){}保护模式:
publicenumProtectionMode{EXACT,CASE_INSENSITIVE,CANONICAL_EQUIVALENT,TOKEN_SET_EQUIVALENT}七、技术Token词法分析器
@ComponentpublicclassTechnicalLexemeExtractor{privatefinalList<LexemeRecognizer>recognizers;publicSet<ProtectedLexeme>extract(Stringquery){Map<String,ProtectedLexeme>result=newLinkedHashMap<>();for(LexemeRecognizerrecognizer:recognizers){for(ProtectedLexemelexeme:recognizer.recognize(query)){result.merge(lexeme.raw(),lexeme,this::preferHigherConfidence);}}returnSet.copyOf(result.values());}}Recognizer示例:
publicinterfaceLexemeRecognizer{List<ProtectedLexeme>recognize(Stringquery);}八、版本号识别
privatestaticfinalPatternVERSION_PATTERN=Pattern.compile("(?i)(?<![a-z0-9])v?\\d+(?:\\.\\d+){1,4}(?:[-+_][a-z0-9.]+)?(?![a-z0-9])");能够匹配:
2.0 V2.3.1 1.0.0-RC1 3.4.0+build7版本比较不能转为普通小数:
2.10 并不小于 2.9需要语义版本模型:
publicrecordSemanticVersion(intmajor,intminor,intpatch,Stringprerelease,StringbuildMetadata){}九、错误码与HTTP状态
privatestaticfinalPatternHTTP_STATUS=Pattern.compile("(?i)\\b(?:HTTP[/\\s]?[12](?:\\.\\d)?\\s*)?([1-5]\\d{2})\\b");privatestaticfinalPatternERROR_CODE=Pattern.compile("\\b[A-Z][A-Z0-9]{1,15}[-_][A-Z0-9-]{1,24}\\b");429在普通文本中可能只是数字,因此需要上下文:
HTTP 429 状态码429 返回429高置信识别后使用EXACT保护。
十、字段名、类名和方法名
ttlMs SearchRequest filterExpression similaritySearch spring.ai.vectorstore.qdrant规则:
- CamelCase;
- snake_case;
- dotted.key;
- 包名;
- 方法调用;
- 注解。
privatestaticfinalPatternCAMEL_CASE=Pattern.compile("\\b[a-z]+(?:[A-Z][a-zA-Z0-9]*)+\\b");privatestaticfinalPatternDOTTED_KEY=Pattern.compile("\\b[a-z][a-z0-9_-]*(?:\\.[a-z0-9_-]+){1,8}\\b");十一、业务缩写词典
publicrecordAcronymEntry(Stringacronym,Set<String>expansions,Set<String>domains,booleanpreserveExact,Stringversion){}例如:
EUDR MCP RAG SKU PUC SSE RRF DBSF不要直接把缩写替换为全称。
推荐Sparse Query:
MCP Streamable HTTPDense Query可以:
MCP(Model Context Protocol)Streamable HTTP即:
扩展 但不删除原缩写十二、占位符保护法
在把Query交给LLM前,将高风险Token替换为不可修改占位符。
publicrecordPlaceholderMap(StringprotectedText,Map<String,String>placeholders){}原始:
ZX-4100B在V2.3后出现E1027保护后:
__LEXEME_001__在__LEXEME_002__后出现__LEXEME_003__模型改写后再恢复。
@ServicepublicclassQueryTokenProtector{publicPlaceholderMapprotect(Stringraw,Set<ProtectedLexeme>lexemes){Stringresult=raw;Map<String,String>map=newLinkedHashMap<>();List<ProtectedLexeme>ordered=lexemes.stream().sorted(Comparator.comparingInt(ProtectedLexeme::start).reversed()).toList();intsequence=1;for(ProtectedLexemelexeme:ordered){Stringplaceholder="__LEXEME_%03d__".formatted(sequence++);result=result.substring(0,lexeme.start())+placeholder+result.substring(lexeme.end());map.put(placeholder,lexeme.raw());}returnnewPlaceholderMap(result,map);}}注意:Offset替换必须从后向前执行。
十三、占位符也可能被模型修改
模型可能输出:
LEXEME_001或删除占位符。
恢复前校验:
publicvoidassertAllPlaceholdersPresent(PlaceholderMapmap,Stringrewritten){Set<String>missing=map.placeholders().keySet().stream().filter(key->!rewritten.contains(key)).collect(Collectors.toSet());if(!missing.isEmpty()){thrownewQueryTokenLossException(missing);}}十四、Sparse Query如何构建
Sparse检索强调词面保真。
publicStringbuildSparseQuery(QueryViewsviews){returnString.join(" ",views.raw(),views.protectedLexemes().stream().map(ProtectedLexeme::raw).distinct().collect(Collectors.joining(" ")));}也可以针对搜索引擎构建Boost:
"ZX-4100B"^5 "E1027"^5 "V2.3"^3 设备 离线^1不要把整句话全部Exact Match,否则召回过窄。
十五、Dense Query如何构建
Dense Query目标是提升语义,但保留关键Token。
publicStringbuildDenseQuery(Stringrewritten,Set<ProtectedLexeme>lexemes){Stringsuffix=lexemes.stream().map(ProtectedLexeme::raw).distinct().collect(Collectors.joining(" "));returnrewritten+"\n关键技术Token:"+suffix;}是否追加Token需通过数据集评测,避免过度影响Embedding。
十六、双路检索
List<Document>sparseDocuments=sparseRetriever.search(views.sparseQuery(),accessContext,40);List<Document>denseDocuments=vectorStoreRetriever.similaritySearch(SearchRequest.builder().query(views.denseQuery()).topK(40).filterExpression(tenantFilter).build());List<Document>fused=fusionService.fuse(sparseDocuments,denseDocuments);技术Token查询通常不能只依赖Dense。
十七、为什么Reranker不能修复Token丢失
Reranker只能在已有候选中重新排序。
如果正确文档因为ZX-4100B被删除而没有进入Top K:
Reranker无文档可排因此Token保真是召回前问题,不是重排问题。
十八、查询改写后的保真校验
publicrecordLexemePreservationReport(booleanpassed,Set<String>missingExact,Set<String>caseChanged,Set<String>canonicalMismatch,Set<String>unexpectedTechnicalTokens){}publicLexemePreservationReportvalidate(Set<ProtectedLexeme>required,Stringrewritten){Set<String>missing=required.stream().filter(lexeme->lexeme.mode()==ProtectionMode.EXACT).map(ProtectedLexeme::raw).filter(token->!rewritten.contains(token)).collect(Collectors.toSet());returnnewLexemePreservationReport(missing.isEmpty(),missing,findCaseChanges(required,rewritten),findCanonicalMismatch(required,rewritten),findUnexpectedTokens(required,rewritten));}失败时:
Dense Query回退到Normalized或Raw Sparse Query继续使用Raw十九、大小写处理策略
推荐同时保存:
raw_token token_lowercase canonical_token检索索引可以存多个字段:
content content_lowercase technical_tokens technical_tokens_keyword查询时:
technical_tokens_keyword精确;content全文;
-向量字段语义。
二十、中英文混合查询
Spring AI的toolcallback.enabled=false为什么仍然注册Tool?不要把:
toolcallback.enabled翻译成中文。
Translation Query策略应:
- 先保护技术Token;
2.只翻译自然语言片段;
3.恢复Token;
4.执行保真校验。
二十一、分词调试
排障时打印不同阶段Token:
publicrecordTokenizationDebug(List<String>rawTokens,List<String>normalizedTokens,List<String>sparseTokens,List<String>denseTokens,Set<String>protectedTokens){}但生产日志中不要直接输出敏感Query。可以在受控调试环境或对Token做Hash。
二十二、指标
rag_query_protected_lexeme_total{ type } rag_query_lexeme_loss_total{ type, stage } rag_query_normalization_change_total{ rule } rag_query_sparse_exact_hit_rate rag_query_dense_recall_rate rag_query_hybrid_required_document_recall rag_query_rewrite_fallback_total{ reason } rag_query_technical_token_query_total关键指标:
技术Token查询的正确文档Recall@K不能只看所有Query平均召回。
二十三、回归样本
HTTP 429 HTTP/2 C++ C# A/B Test Spring AI 2.0 v1.2.10 ZX-4100B E1027 tenant_id filterExpression @McpTool ttlMs 95% 2026-08-08 HT-2026-0081每条Case定义:
- 必须保留Token;
-允许的Canonical形式;
-期望文档;
-禁止文档;
-Sparse/Dense/Hybrid结果。
二十四、自动化测试
@ParameterizedTest@MethodSource("technicalTokenCases")voidprotectedTokensMustSurvive(Stringquery,Set<String>expected){QueryViewsviews=queryViewService.build(query);assertThat(views.protectedLexemes().stream().map(ProtectedLexeme::raw)).containsAll(expected);assertThat(views.sparseQuery()).contains(expected.toArray(String[]::new));}检索集成测试:
@TestvoidmodelNumberMustRetrieveExactManual(){RetrievalResultresult=ragRetriever.retrieve("ZX-4100B出现E1027怎么办?");assertThat(result.documents()).extracting(Document::getId).contains("MANUAL-ZX-4100B-E1027");}二十五、完整排查顺序
1. 检查query_raw 2. 检查Unicode和全角半角转换 3. 检查清洗正则 4. 检查停用词表 5. 检查分词结果 6. 检查技术Token提取 7. 检查占位符是否被删除 8. 检查改写输出 9. 检查Sparse实际Query 10. 检查Dense实际Query 11. 检查索引是否存储完整Token 12. 检查融合与Reranker候选如果索引阶段已经把连字符和大小写全部丢失,仅修复查询侧仍不够,需要重建相应字段。
二十六、常见错误
为了中文分词删除全部非中文字符 对所有Query统一转小写 将版本号当普通小数 停用词表直接应用于技术Query 只保存改写Query,不保存Raw 只做Dense,不做Sparse 依赖Reranker修复召回缺失 翻译Query时不保护代码和字段名二十七、上线前检查清单
□ Raw Query不可变保存 □ Normalization规则可追踪且版本化 □ 技术Token在清洗前抽取 □ 版本、错误码、型号和字段名有专用Recognizer □ 业务缩写词典可版本化 □ 高风险Token使用占位符保护 □ 占位符恢复前检查完整性 □ Sparse Query保留原始Token □ Dense Query允许语义改写但通过保真校验 □ Hybrid检索使用稳定融合 □ 索引中存在技术Token精确字段 □ 技术Query有独立黄金数据集 □ Token损失指标进入质量门禁总结
技术RAG的查询处理目标不是把句子变得更自然,而是最大限度保留检索信息。
推荐架构:
Raw Query → 技术Token抽取 → 可解释Normalization → Sparse Query保真 → Dense Query受控增强 → Token校验 → Hybrid Retrieval数字、缩写、型号和错误码一旦在检索前丢失,后面的向量库、Reranker和大模型通常无法恢复。把这些Token视为一等数据,而不是清洗噪声,是技术知识库从Demo走向生产的基础。
