Filtering noise from RAG chunks before they poison retrieval
After splitting a document, not every chunk is worth keeping. Ads, nav bars, disclaimers, and duplicates are noise that pollutes retrieval. This walks rule-based, structural, semantic, and LLM filtering strategies, with a Java pipeline you can adapt.
In a RAG system, after a document is split, not all content is worth keeping — a lot of it is noise (ads, navigation bars, disclaimers, duplicate content) that pollutes retrieval results.
1. The core idea of filtering
Goal: before or after splitting, filter out content that doesn’t help answer the user’s question.
raw document → content filtering → useful content → split → vectorize → retrieve
↓
noise (discarded)
Filtering has three main dimensions:
- Structural noise: web navigation, headers/footers, copyright notices.
- Content noise: ads, recommendation links, irrelevant image descriptions.
- Quality noise: overly short/long meaningless paragraphs, duplicate content.
2. Common filtering strategies
Strategy 1: Rule-based filtering (simplest, fastest)
public class RuleBasedFilter {
// 1. Length filter: chunks that are too short (<20 chars) or too long (>2000 chars) are usually low quality
public boolean isValidByLength(String chunk) {
if (chunk.length() < 20) return false; // meaningless short sentence
if (chunk.length() > 2000) return false; // likely contains too much noise
return true;
}
// 2. Keyword blacklist: content containing these words is likely noise
private static final List<String> NOISE_PATTERNS = Arrays.asList(
"click here", "subscribe for free", "all rights reserved", "privacy policy",
"terms of use", "ad", "sponsor", "related recommendation",
"cookie", "GDPR", "All rights reserved"
);
public boolean isNotNoiseByKeywords(String chunk) {
String lower = chunk.toLowerCase();
for (String pattern : NOISE_PATTERNS) {
if (lower.contains(pattern.toLowerCase())) {
return false; // hit a noise word, filter out
}
}
return true;
}
// 3. Punctuation-density filter: content that's all punctuation/numbers/symbols isn't meaningful
public boolean hasSubstance(String chunk) {
int chineseCharCount = 0;
for (char c : chunk.toCharArray()) {
if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) {
chineseCharCount++;
}
}
// content with <20% Chinese chars may not be valid text
double ratio = (double) chineseCharCount / chunk.length();
return ratio > 0.2;
}
// 4. Deduplication: adjacent duplicate chunks
public List<String> deduplicate(List<String> chunks) {
List<String> result = new ArrayList<>();
String prev = null;
for (String chunk : chunks) {
if (prev == null || !isDuplicate(prev, chunk)) {
result.add(chunk);
prev = chunk;
}
}
return result;
}
private boolean isDuplicate(String a, String b) {
// simple dedup: similarity > 90% counts as duplicate
int minLen = Math.min(a.length(), b.length());
int sameCount = 0;
for (int i = 0; i < minLen; i++) {
if (a.charAt(i) == b.charAt(i)) sameCount++;
}
double similarity = (double) sameCount / minLen;
return similarity > 0.9;
}
}
Strategy 2: Document-structure filtering (for web/PDF)
public class StructuralFilter {
// For HTML: extract the main body, drop nav/sidebar/footer
public String extractMainContent(String html) {
Document doc = Jsoup.parse(html);
doc.select("nav, header, footer, aside, .sidebar, .ad, .recommendation").remove();
doc.select("script, style, noscript").remove();
Element main = doc.selectFirst("main, article, .content, body");
return main != null ? main.text() : doc.body().text();
}
// For PDF: identify and skip headers/footers
public boolean isHeaderOrFooter(String text, int pageNum, int totalPages) {
boolean hasPageNumber = text.matches(".*\\d+.*") && text.length() < 50;
boolean hasDate = text.matches(".*\\d{4}[./-]\\d{2}[./-]\\d{2}.*");
if (pageNum == 1 && text.length() < 100) return false; // possibly a title
if (pageNum > 1 && hasPageNumber && text.length() < 100) return true;
if ((pageNum == 1 || pageNum == totalPages) && text.contains("Copyright")) return true;
return false;
}
}
Strategy 3: Semantic filtering (smartest, needs embedding)
Idea: use an embedding model to judge how similar a text chunk is to “useful content.”
public class SemanticFilter {
private EmbeddingProvider embeddingProvider;
private static final List<String> POSITIVE_EXAMPLES = Arrays.asList(
"Company annual leave policy is 15 days per year",
"Server spec: 16GB RAM, 4-core CPU",
"Customer complaint process: step 1, log the complaint"
);
private static final List<String> NEGATIVE_EXAMPLES = Arrays.asList(
"Click here for more information",
"© 2024 Company. All rights reserved",
"Related recommendation: you may also like"
);
private float[] positiveCenter;
private float[] negativeCenter;
public SemanticFilter(EmbeddingProvider provider) {
this.embeddingProvider = provider;
this.positiveCenter = computeCenter(POSITIVE_EXAMPLES);
this.negativeCenter = computeCenter(NEGATIVE_EXAMPLES);
}
private float[] computeCenter(List<String> texts) {
float[] sum = null;
for (String text : texts) {
float[] vec = embeddingProvider.getEmbedding(text);
if (sum == null) sum = new float[vec.length];
for (int i = 0; i < vec.length; i++) sum[i] += vec[i];
}
for (int i = 0; i < sum.length; i++) sum[i] /= texts.size();
return sum;
}
public boolean isUseful(String chunk) {
float[] chunkVec = embeddingProvider.getEmbedding(chunk);
double simToPositive = cosineSimilarity(chunkVec, positiveCenter);
double simToNegative = cosineSimilarity(chunkVec, negativeCenter);
return simToPositive > 0.6 && simToNegative < 0.4;
}
}
Strategy 4: LLM filtering (most accurate, but costly)
Directly let the model judge whether a chunk is useful:
public class LLMFilter {
public boolean isUseful(String chunk, LLMClient llm) {
String prompt = String.format("""
Decide whether the following text fragment contains valuable information
(knowledge, facts, explanations, procedures, etc.).
Ignore: ads, navigation menus, copyright notices, recommendation links, duplicates.
Answer only "yes" or "no".
Text: %s
""", chunk);
String response = llm.chat(prompt);
return response.trim().equalsIgnoreCase("yes");
}
}
3. A complete filtering pipeline
public class DocumentFilterPipeline {
private RuleBasedFilter ruleFilter = new RuleBasedFilter();
private StructuralFilter structuralFilter = new StructuralFilter();
private SemanticFilter semanticFilter; // optional
private LLMFilter llmFilter; // optional
public List<String> filterAndChunk(String rawDocument) {
// 1. structural filtering (for HTML/PDF)
String mainContent = structuralFilter.extractMainContent(rawDocument);
// 2. split
List<String> chunks = chunker.chunk(mainContent);
// 3. rule filtering (quickly drop obvious noise)
List<String> filtered = new ArrayList<>();
for (String chunk : chunks) {
if (ruleFilter.isValidByLength(chunk) &&
ruleFilter.isNotNoiseByKeywords(chunk) &&
ruleFilter.hasSubstance(chunk)) {
filtered.add(chunk);
}
}
// 4. deduplicate
filtered = ruleFilter.deduplicate(filtered);
// 5. semantic filtering (optional, costly)
// if (semanticFilter != null) { ... }
return filtered;
}
}
4. Filtering recommendations by scenario
| Scenario | Recommended strategy | Why |
|---|---|---|
| Internal docs (Word/PDF) | rule + structure | little noise, mainly headers/footers, TOC |
| Web-scraped content | structure (extract body) + keyword blacklist | web has the most noise (ads, nav) |
| Chat logs | length filter + dedup | many short messages, lots of duplicates |
| Academic papers | title/abstract/conclusion first + body filter | need to recognize paper structure |
| Pursuing highest quality | semantic or LLM filtering | best effect, highest cost |
5. Java utilities to get started quickly
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version> <!-- HTML parsing -->
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version> <!-- string utils -->
</dependency>
public class TextQualityChecker {
public static boolean looksLikeNormalText(String text) {
int validChars = 0;
for (char c : text.toCharArray()) {
if (Character.isLetterOrDigit(c) ||
",。!?;:\"\"''()【】《》、".indexOf(c) != -1) {
validChars++;
}
}
double validRatio = (double) validChars / text.length();
if (validRatio < 0.6) return false; // too many special chars
if (text.matches(".*(.)\\1{5,}.*")) return false; // repeated chars like "......"
int upperCount = 0;
for (char c : text.toCharArray()) {
if (Character.isUpperCase(c)) upperCount++;
}
if (upperCount > text.length() * 0.5) return false; // all-caps is noise
return true;
}
}
Takeaway
The core of filtering is layered progression:
fastest, lowest cost → rule filtering (length, keywords, punctuation)
↓ not enough
structure filtering (HTML body extraction, PDF header/footer)
↓ still not enough
semantic filtering (embedding similarity)
↓ pursue the extreme
LLM filtering (let the model judge)
Production recommendation: first use rule + structure filtering to drop ~80% of obvious noise, then use semantic filtering for edge cases, and reserve LLM filtering for small-scale, high-quality scenarios (cost is too high otherwise).
Follow me