Sửa tài liệu RAG không phải ghi đè: cập nhật đúng cách cho LLM
1. Tổng quan
Trong hệ thống RAG (Retrieval-Augmented Generation), tài liệu nguồn không bao giờ đứng yên: chính sách đổi, giá đổi, quy định đổi. Nếu bạn chỉ “ghi đè” chunk cũ bằng chunk mới trong vector store, hệ thống rất dễ rơi vào trạng thái vừa trả lời đúng vừa trả lời sai cùng một câu hỏi. Bài viết này giải thích tại sao ghi đè đơn giản là một thiết kế nguy hiểm, và trình bày một quy trình cập nhật an toàn hơn: chuẩn bị chunk mới, xác minh, rồi chuyển active version — có code Java minh họa cụ thể.
2. Vấn đề: tại sao ghi đè trực tiếp gây mâu thuẫn
Giả sử công ty bạn tăng hạn mức hoàn tiền khách sạn từ 5.000.000đ lên 7.000.000đ. Tài liệu chính sách được chia thành nhiều chunk, mỗi chunk được embed thành vector và lưu trong vector database. Khi policy đổi, nếu bạn:
- Insert chunk mới trước, xóa chunk cũ sau → có một khoảng thời gian cả hai chunk cùng tồn tại và cùng được coi là “hợp lệ cho câu hỏi hiện tại”. Truy vấn semantic search có thể trả về chunk cũ (5.000.000đ) vì độ tương đồng vector của nó với câu hỏi vẫn cao.
- Xóa chunk cũ trước, insert chunk mới sau → có một khoảng thời gian không chunk nào trả lời được câu hỏi về hạn mức, LLM buộc phải “bịa” hoặc trả lời rằng không có thông tin.
Cả hai cách đều có vấn đề vì chúng coi “cập nhật” là một hành động đơn lẻ, trong khi thực chất nó là hai thao tác (thêm mới + loại bỏ cũ) không nguyên tử (atomic).
// Cách làm SAI: ghi đè ngầm định, không kiểm soát trạng thái transition
public void updatePolicyNaive(VectorStore store, String oldChunkId, PolicyChunk newChunk) {
store.delete(oldChunkId); // (1) có khoảng trống tạm thời
store.upsert(newChunk.id(), newChunk.embedding(), newChunk.metadata());
}
Dòng (1) tạo ra một khoảng trống: trong lúc delete đã chạy nhưng upsert chưa xong (hoặc lỗi giữa đường), truy vấn sẽ không tìm thấy chunk nào về hạn mức hoàn tiền.
Lưu ý: Với hệ thống RAG chạy production, khoảng trống này không phải lý thuyết — nó xảy ra thật mỗi lần bạn deploy một job cập nhật dữ liệu mà không có cơ chế transaction hoặc versioning.
3. Khi nào cần re-embed, khi nào không
Không phải mọi thay đổi đều cần tính lại vector. Vector được sinh ra từ nội dung văn bản của chunk. Nếu bạn chỉ đổi metadata (ví dụ: ngày hiệu lực, phòng ban áp dụng) mà không đổi câu chữ trong chunk, bạn không cần re-embed — chỉ cần update metadata.
public record PolicyChunk(
String id,
String text, // nội dung dùng để sinh vector
float[] embedding,
Map<String, String> metadata // KHÔNG nằm trong text dùng để embed
) {}
Nếu text thay đổi (ví dụ “5.000.000đ” → “7.000.000đ”), vector cũ không còn phản ánh đúng nội dung → phải re-embed chunk đó. Nhưng các chunk khác trong tài liệu (ví dụ phần nói về điều kiện áp dụng, không đổi số tiền) vẫn giữ nguyên vector — không cần đụng tới.
public interface VectorStore {
void upsertVector(String chunkId, float[] embedding);
void upsertMetadata(String chunkId, Map<String, String> metadata);
Optional<PolicyChunk> findById(String chunkId);
void delete(String chunkId);
}
Tách upsertVector và upsertMetadata thành hai method riêng giúp bạn chủ động chọn: đổi metadata không re-embed, đổi text thì re-embed. Một số vector database (Qdrant, Weaviate, Pinecone) hỗ trợ cập nhật payload/metadata độc lập với vector — hãy kiểm tra tài liệu của sản phẩm bạn dùng trước khi viết logic update.
4. Thiết kế version và active-version flag
Thay vì sửa chunk tại chỗ, cách an toàn hơn là coi mỗi lần thay đổi nội dung là một phiên bản mới, có versionId riêng. Chunk cũ không bị xóa ngay — nó chỉ bị đánh dấu không còn “active” cho truy vấn chính sách hiện hành.
public record PolicyVersion(
String versionId, // ví dụ: "hotel-reimbursement-v2"
String policyKey, // ví dụ: "hotel-reimbursement"
List<PolicyChunk> chunks,
Instant effectiveFrom
) {}
public class ActiveVersionRegistry {
private final Map<String, String> activeVersionByPolicyKey = new ConcurrentHashMap<>();
public void setActive(String policyKey, String versionId) {
activeVersionByPolicyKey.put(policyKey, versionId);
}
public Optional<String> getActiveVersion(String policyKey) {
return Optional.ofNullable(activeVersionByPolicyKey.get(policyKey));
}
}
ActiveVersionRegistry chỉ lưu một ánh xạ: với mỗi policyKey, version nào đang được coi là “hiện hành”. Khi search, bạn filter theo metadata.versionId == activeVersion, chứ không dựa vào việc chunk còn tồn tại hay không. Nhờ vậy, việc chuyển đổi version trở thành một thao tác đổi con trỏ, nhanh và gần như nguyên tử — không phải chuỗi insert/delete rải rác trên nhiều chunk.
5. Quy trình cập nhật an toàn: chuẩn bị → xác minh → chuyển active
Quy trình gồm ba bước rõ ràng, tránh cả hai lỗi ở mục 2.
@Service
public class PolicyUpdateService {
private final VectorStore vectorStore;
private final EmbeddingClient embeddingClient;
private final ActiveVersionRegistry registry;
public PolicyUpdateService(VectorStore vectorStore,
EmbeddingClient embeddingClient,
ActiveVersionRegistry registry) {
this.vectorStore = vectorStore;
this.embeddingClient = embeddingClient;
this.registry = registry;
}
public void applyNewVersion(String policyKey, PolicyVersion newVersion) {
// Bước 1: chuẩn bị — embed và ghi chunk mới, CHƯA đổi active version
for (PolicyChunk chunk : newVersion.chunks()) {
float[] vector = embeddingClient.embed(chunk.text());
Map<String, String> metadata = new HashMap<>(chunk.metadata());
metadata.put("versionId", newVersion.versionId());
metadata.put("policyKey", policyKey);
vectorStore.upsertVector(chunk.id(), vector);
vectorStore.upsertMetadata(chunk.id(), metadata);
}
// Bước 2: xác minh — chunk mới thật sự đọc được, không rỗng
boolean ready = newVersion.chunks().stream()
.allMatch(c -> vectorStore.findById(c.id()).isPresent());
if (!ready) {
throw new IllegalStateException("Chunk mới chưa sẵn sàng, không chuyển active version");
}
// Bước 3: chuyển active version — thao tác nhanh, gần như atomic
registry.setActive(policyKey, newVersion.versionId());
}
}
Giải thích luồng:
- Bước 1 chạy trước khi bất kỳ truy vấn nào bị ảnh hưởng — chunk mới tồn tại song song với chunk cũ, nhưng chunk cũ vẫn là active nên người dùng vẫn nhận câu trả lời nhất quán (5.000.000đ) trong suốt quá trình chuẩn bị.
- Bước 2 đảm bảo bạn không chuyển active version khi dữ liệu mới chưa ghi xong hoặc bị lỗi một phần — tránh tình huống “đổi cờ nhưng chunk chưa có”.
- Bước 3 chỉ đổi một giá trị trong registry. Đây là điểm chuyển đổi duy nhất, nên bạn dễ kiểm soát và dễ rollback (chỉ cần set lại
versionIdcũ).
Chunk cũ (v1) không bị xóa ngay. Bạn có thể dọn dẹp nó sau, ở một job riêng, không nằm trên đường critical path của việc cập nhật chính sách.
6. Giữ khả năng trả lời câu hỏi lịch sử
Một lợi ích phụ của thiết kế versioning: bạn vẫn trả lời được câu hỏi như “Năm ngoái hạn mức hoàn tiền khách sạn là bao nhiêu?”. Vì chunk v1 chưa bị xóa, bạn chỉ cần một filter khác ở tầng retrieval.
public List<PolicyChunk> searchCurrentPolicy(String policyKey, float[] queryVector) {
String activeVersion = registry.getActiveVersion(policyKey)
.orElseThrow(() -> new IllegalStateException("Không có version active cho " + policyKey));
return vectorStore.searchSimilar(queryVector, Map.of(
"policyKey", policyKey,
"versionId", activeVersion
));
}
public List<PolicyChunk> searchHistoricalPolicy(String policyKey, String versionId, float[] queryVector) {
return vectorStore.searchSimilar(queryVector, Map.of(
"policyKey", policyKey,
"versionId", versionId
));
}
searchCurrentPolicy luôn filter theo activeVersion lấy từ registry — đây là nơi duy nhất quyết định “phiên bản nào đang hiện hành”. searchHistoricalPolicy cho phép truy vấn tường minh một version cụ thể, phục vụ các câu hỏi mang tính lịch sử hoặc audit.
7. Test cho hành vi chuyển active version
Viết test để đảm bảo quy trình không bao giờ để lộ trạng thái “nửa vời” ra ngoài.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
class PolicyUpdateServiceTest {
@Test
void shouldKeepOldVersionActiveUntilNewVersionVerified() {
InMemoryVectorStore store = new InMemoryVectorStore();
ActiveVersionRegistry registry = new ActiveVersionRegistry();
registry.setActive("hotel-reimbursement", "v1");
PolicyUpdateService service = new PolicyUpdateService(
store, text -> new float[]{0.1f, 0.2f}, registry);
PolicyVersion v2 = new PolicyVersion(
"v2", "hotel-reimbursement",
List.of(new PolicyChunk("chunk-v2-1", "Hạn mức 7.000.000đ", null, Map.of())),
Instant.now());
service.applyNewVersion("hotel-reimbursement", v2);
assertThat(registry.getActiveVersion("hotel-reimbursement"))
.contains("v2");
// chunk v1 vẫn còn trong store, phục vụ truy vấn lịch sử
assertThat(store.findById("chunk-v1-1")).isPresent();
}
}
Test này khẳng định hai điều: sau khi applyNewVersion chạy xong, active version phải là v2, và chunk của v1 không bị mất — bạn vẫn truy vấn lịch sử được.
8. Đổi embedding model: bài toán lớn hơn
Mọi thứ ở trên giả định embedding model không đổi. Nếu bạn chuyển từ model A sang model B (ví dụ đổi provider, đổi số chiều vector), toàn bộ chunk đang tồn tại cần được embed lại bằng model mới — không chỉ chunk vừa sửa nội dung. Vector của model A và model B không nằm trong cùng không gian, so sánh cosine similarity giữa chúng vô nghĩa.
public void migrateEmbeddingModel(EmbeddingClient newModel, List<PolicyChunk> allChunks) {
for (PolicyChunk chunk : allChunks) {
float[] newVector = newModel.embed(chunk.text());
vectorStore.upsertVector(chunk.id() + ":model-v2", newVector);
}
// Sau khi toàn bộ chunk có vector mới, đổi điểm vào (entry point) của query pipeline
// để các câu query cũng dùng newModel khi embed câu hỏi.
}
Lưu ý mấu chốt: câu query của người dùng cũng phải được embed bằng đúng model mới tại thời điểm search. Nếu bạn chỉ đổi vector của document mà quên đổi model embed câu hỏi, kết quả tìm kiếm sẽ sai lệch hoàn toàn vì hai phía không còn ở cùng không gian vector.
9. Lỗi hay gặp và khi nào không cần làm phức tạp
- Lỗi thường gặp: coi update là một
upsert()duy nhất, không tách biệt “ghi dữ liệu mới” và “chuyển active” — dẫn đến khoảng trống hoặc trùng lặp đã nói ở mục 2. - Lỗi thường gặp: để metadata nằm trong phần text dùng để embed (ví dụ nhúng cả ngày hiệu lực vào câu văn), khiến mọi thay đổi metadata buộc phải re-embed, tốn tài nguyên không cần thiết.
- Lỗi thường gặp: quên rằng chunk cũ cần giữ lại cho truy vấn lịch sử — xóa ngay sau khi chuyển active, làm mất khả năng trả lời “trước đây chính sách thế nào”.
Khi nào KHÔNG cần toàn bộ quy trình versioning này: nếu tài liệu của bạn gần như tĩnh (ít thay đổi, không có yêu cầu truy vấn lịch sử, hệ thống chỉ chạy nội bộ với vài chục người dùng), một job batch re-index toàn bộ theo lịch (ví dụ mỗi đêm) đơn giản hơn và đủ an toàn — không cần registry, không cần versionId phức tạp.
10. Kết luận
Cập nhật tài liệu trong hệ thống RAG không phải là bài toán “ghi đè vector cũ bằng vector mới” — nó là bài toán quản lý trạng thái chuyển tiếp giữa hai phiên bản dữ liệu mà không để hệ thống rơi vào khoảng trống hoặc mâu thuẫn. Tách biệt vector và metadata để tránh re-embed không cần thiết, dùng versionId cùng active-version registry để chuyển đổi nhanh và có thể rollback, và luôn giữ lại dữ liệu cũ đủ lâu để phục vụ truy vấn lịch sử. Khi đổi cả embedding model, hãy coi đó là một migration toàn diện, không phải một update thông thường.